merge: wave 2 executor (01-02) into main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a5b8fcf9d3
commit
aa1a78d7d9
1081 files changed
+445341
No files matched your search
@@ -0,0 +1,350 @@
|
||||
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";
|
||||
import { OnboardingWizard } from "./components/OnboardingWizard";
|
||||
import { authApi } from "./api/auth";
|
||||
import { healthApi } from "./api/health";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Companies } from "./pages/Companies";
|
||||
import { Agents } from "./pages/Agents";
|
||||
import { AgentDetail } from "./pages/AgentDetail";
|
||||
import { Projects } from "./pages/Projects";
|
||||
import { ProjectDetail } from "./pages/ProjectDetail";
|
||||
import { Issues } from "./pages/Issues";
|
||||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { Routines } from "./pages/Routines";
|
||||
import { RoutineDetail } from "./pages/RoutineDetail";
|
||||
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
||||
import { Goals } from "./pages/Goals";
|
||||
import { GoalDetail } from "./pages/GoalDetail";
|
||||
import { Approvals } from "./pages/Approvals";
|
||||
import { ApprovalDetail } from "./pages/ApprovalDetail";
|
||||
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({ 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">
|
||||
{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`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudAccessGate() {
|
||||
const location = useLocation();
|
||||
const healthQuery = useQuery({
|
||||
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";
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: isAuthenticatedMode,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
if (healthQuery.error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
|
||||
{healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
|
||||
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && !sessionQuery.data) {
|
||||
const next = encodeURIComponent(`${location.pathname}${location.search}`);
|
||||
return <Navigate to={`/auth?next=${next}`} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function boardRoutes() {
|
||||
return (
|
||||
<>
|
||||
<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 />} />
|
||||
<Route path="agents/active" element={<Agents />} />
|
||||
<Route path="agents/paused" element={<Agents />} />
|
||||
<Route path="agents/error" element={<Agents />} />
|
||||
<Route path="agents/new" element={<NewAgent />} />
|
||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
||||
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
||||
<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/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 />} />
|
||||
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
|
||||
<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 />} />
|
||||
<Route path="approvals/pending" element={<Approvals />} />
|
||||
<Route path="approvals/all" element={<Approvals />} />
|
||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||
<Route path="costs" element={<Costs />} />
|
||||
<Route path="activity" element={<Activity />} />
|
||||
<Route path="inbox" element={<InboxRootRedirect />} />
|
||||
<Route path="inbox/recent" element={<Inbox />} />
|
||||
<Route path="inbox/unread" element={<Inbox />} />
|
||||
<Route path="inbox/all" element={<Inbox />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
|
||||
<Route path="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 location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||
if (!targetCompany) {
|
||||
if (
|
||||
shouldRedirectCompanylessRouteToOnboarding({
|
||||
pathname: location.pathname,
|
||||
hasCompanies: false,
|
||||
})
|
||||
) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
return <NoCompaniesStartPage />;
|
||||
}
|
||||
|
||||
return <Navigate to={`/${targetCompany.issuePrefix}/dashboard`} replace />;
|
||||
}
|
||||
|
||||
function UnprefixedBoardRedirect() {
|
||||
const location = useLocation();
|
||||
const { companies, selectedCompany, loading } = useCompany();
|
||||
|
||||
if (loading) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||
if (!targetCompany) {
|
||||
if (
|
||||
shouldRedirectCompanylessRouteToOnboarding({
|
||||
pathname: location.pathname,
|
||||
hasCompanies: false,
|
||||
})
|
||||
) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
return <NoCompaniesStartPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to={`/${targetCompany.issuePrefix}${location.pathname}${location.search}${location.hash}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NoCompaniesStartPage() {
|
||||
const { openOnboarding } = useDialog();
|
||||
|
||||
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">Create your first company</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Get started by creating a company.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button onClick={() => openOnboarding()}>New Company</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
<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 />} />
|
||||
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="agents/:agentId/runs/:runId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId" element={<UnprefixedBoardRedirect />} />
|
||||
<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/configuration" 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
ToggleField,
|
||||
DraftInput,
|
||||
DraftNumberInput,
|
||||
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.";
|
||||
|
||||
export function ClaudeLocalConfigFields({
|
||||
mode,
|
||||
isCreate,
|
||||
adapterType,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
models,
|
||||
hideInstructionsFile,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
{!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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClaudeLocalAdvancedFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<ToggleField
|
||||
label="Enable Chrome"
|
||||
hint={help.chrome}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.chrome
|
||||
: eff("adapterConfig", "chrome", config.chrome === true)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ chrome: v })
|
||||
: mark("adapterConfig", "chrome", v)
|
||||
}
|
||||
/>
|
||||
<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)
|
||||
}
|
||||
/>
|
||||
<Field label="Max turns per run" hint={help.maxTurnsPerRun}>
|
||||
{isCreate ? (
|
||||
<input
|
||||
type="number"
|
||||
className={inputClass}
|
||||
value={values!.maxTurnsPerRun}
|
||||
onChange={(e) => set!({ maxTurnsPerRun: Number(e.target.value) })}
|
||||
/>
|
||||
) : (
|
||||
<DraftNumberInput
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"maxTurnsPerRun",
|
||||
Number(config.maxTurnsPerRun ?? 300),
|
||||
)}
|
||||
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 300)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseClaudeStdoutLine } from "@paperclipai/adapter-claude-local/ui";
|
||||
import { ClaudeLocalConfigFields } from "./config-fields";
|
||||
import { buildClaudeLocalConfig } from "@paperclipai/adapter-claude-local/ui";
|
||||
|
||||
export const claudeLocalUIAdapter: UIAdapterModule = {
|
||||
type: "claude_local",
|
||||
label: "Claude Code (local)",
|
||||
parseStdoutLine: parseClaudeStdoutLine,
|
||||
ConfigFields: ClaudeLocalConfigFields,
|
||||
buildAdapterConfig: buildClaudeLocalConfig,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
ToggleField,
|
||||
DraftInput,
|
||||
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. 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 (
|
||||
<>
|
||||
{!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}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.dangerouslyBypassSandbox
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"dangerouslyBypassApprovalsAndSandbox",
|
||||
bypassEnabled,
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslyBypassSandbox: v })
|
||||
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Enable search"
|
||||
hint={help.search}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.search
|
||||
: eff("adapterConfig", "search", !!config.search)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ search: v })
|
||||
: mark("adapterConfig", "search", v)
|
||||
}
|
||||
/>
|
||||
<LocalWorkspaceRuntimeFields
|
||||
isCreate={isCreate}
|
||||
values={values}
|
||||
set={set}
|
||||
config={config}
|
||||
mark={mark}
|
||||
eff={eff}
|
||||
mode={mode}
|
||||
adapterType={adapterType}
|
||||
models={models}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseCodexStdoutLine } from "@paperclipai/adapter-codex-local/ui";
|
||||
import { CodexLocalConfigFields } from "./config-fields";
|
||||
import { buildCodexLocalConfig } from "@paperclipai/adapter-codex-local/ui";
|
||||
|
||||
export const codexLocalUIAdapter: UIAdapterModule = {
|
||||
type: "codex_local",
|
||||
label: "Codex (local)",
|
||||
parseStdoutLine: parseCodexStdoutLine,
|
||||
ConfigFields: CodexLocalConfigFields,
|
||||
buildAdapterConfig: buildCodexLocalConfig,
|
||||
};
|
||||
@@ -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 prompt at runtime.";
|
||||
|
||||
export function CursorLocalConfigFields({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseCursorStdoutLine } from "@paperclipai/adapter-cursor-local/ui";
|
||||
import { CursorLocalConfigFields } from "./config-fields";
|
||||
import { buildCursorLocalConfig } from "@paperclipai/adapter-cursor-local/ui";
|
||||
|
||||
export const cursorLocalUIAdapter: UIAdapterModule = {
|
||||
type: "cursor",
|
||||
label: "Cursor CLI (local)",
|
||||
parseStdoutLine: parseCursorStdoutLine,
|
||||
ConfigFields: CursorLocalConfigFields,
|
||||
buildAdapterConfig: buildCursorLocalConfig,
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CreateConfigValues } from "../../components/AgentConfigForm";
|
||||
|
||||
export function buildHttpConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.url) ac.url = v.url;
|
||||
ac.method = "POST";
|
||||
ac.timeoutMs = 15000;
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
|
||||
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";
|
||||
|
||||
export function HttpConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<Field label="Webhook URL" hint={help.webhookUrl}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.url
|
||||
: eff("adapterConfig", "url", String(config.url ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ url: v })
|
||||
: mark("adapterConfig", "url", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseHttpStdoutLine } from "./parse-stdout";
|
||||
import { HttpConfigFields } from "./config-fields";
|
||||
import { buildHttpConfig } from "./build-config";
|
||||
|
||||
export const httpUIAdapter: UIAdapterModule = {
|
||||
type: "http",
|
||||
label: "HTTP Webhook",
|
||||
parseStdoutLine: parseHttpStdoutLine,
|
||||
ConfigFields: HttpConfigFields,
|
||||
buildAdapterConfig: buildHttpConfig,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { TranscriptEntry } from "../types";
|
||||
|
||||
export function parseHttpStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { getUIAdapter, listUIAdapters } from "./registry";
|
||||
export { buildTranscript } from "./transcript";
|
||||
export type {
|
||||
TranscriptEntry,
|
||||
StdoutLineParser,
|
||||
UIAdapterModule,
|
||||
AdapterConfigFieldsProps,
|
||||
} from "./types";
|
||||
export type { RunLogChunk } from "./transcript";
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { AdapterConfigFieldsProps } from "./types";
|
||||
|
||||
export function LocalWorkspaceRuntimeFields(_props: AdapterConfigFieldsProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
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";
|
||||
|
||||
function SecretField({
|
||||
label,
|
||||
value,
|
||||
onCommit,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onCommit: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{visible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<DraftInput
|
||||
value={value}
|
||||
onCommit={onCommit}
|
||||
immediate
|
||||
type={visible ? "text" : "password"}
|
||||
className={inputClass + " pl-8"}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function parseScopes(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === "string").join(", ");
|
||||
}
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function OpenClawGatewayConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
const configuredHeaders =
|
||||
config.headers && typeof config.headers === "object" && !Array.isArray(config.headers)
|
||||
? (config.headers as Record<string, unknown>)
|
||||
: {};
|
||||
const effectiveHeaders =
|
||||
(eff("adapterConfig", "headers", configuredHeaders) as Record<string, unknown>) ?? {};
|
||||
|
||||
const effectiveGatewayToken = typeof effectiveHeaders["x-openclaw-token"] === "string"
|
||||
? String(effectiveHeaders["x-openclaw-token"])
|
||||
: typeof effectiveHeaders["x-openclaw-auth"] === "string"
|
||||
? String(effectiveHeaders["x-openclaw-auth"])
|
||||
: "";
|
||||
|
||||
const commitGatewayToken = (rawValue: string) => {
|
||||
const nextValue = rawValue.trim();
|
||||
const nextHeaders: Record<string, unknown> = { ...effectiveHeaders };
|
||||
if (nextValue) {
|
||||
nextHeaders["x-openclaw-token"] = nextValue;
|
||||
delete nextHeaders["x-openclaw-auth"];
|
||||
} else {
|
||||
delete nextHeaders["x-openclaw-token"];
|
||||
delete nextHeaders["x-openclaw-auth"];
|
||||
}
|
||||
mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined);
|
||||
};
|
||||
|
||||
const sessionStrategy = eff(
|
||||
"adapterConfig",
|
||||
"sessionKeyStrategy",
|
||||
String(config.sessionKeyStrategy ?? "fixed"),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field label="Gateway URL" hint={help.webhookUrl}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.url
|
||||
: eff("adapterConfig", "url", String(config.url ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ url: v })
|
||||
: mark("adapterConfig", "url", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="ws://127.0.0.1:18789"
|
||||
/>
|
||||
</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">
|
||||
<DraftInput
|
||||
value={
|
||||
eff(
|
||||
"adapterConfig",
|
||||
"paperclipApiUrl",
|
||||
String(config.paperclipApiUrl ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) => mark("adapterConfig", "paperclipApiUrl", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://paperclip.example"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Session strategy">
|
||||
<select
|
||||
value={sessionStrategy}
|
||||
onChange={(e) => mark("adapterConfig", "sessionKeyStrategy", e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="issue">Per issue</option>
|
||||
<option value="run">Per run</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{sessionStrategy === "fixed" && (
|
||||
<Field label="Session key">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "sessionKey", String(config.sessionKey ?? "paperclip"))}
|
||||
onCommit={(v) => mark("adapterConfig", "sessionKey", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="paperclip"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<SecretField
|
||||
label="Gateway auth token (x-openclaw-token)"
|
||||
value={effectiveGatewayToken}
|
||||
onCommit={commitGatewayToken}
|
||||
placeholder="OpenClaw gateway token"
|
||||
/>
|
||||
|
||||
<Field label="Role">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "role", String(config.role ?? "operator"))}
|
||||
onCommit={(v) => mark("adapterConfig", "role", v || undefined)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="operator"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Scopes (comma-separated)">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "scopes", parseScopes(config.scopes ?? ["operator.admin"]))}
|
||||
onCommit={(v) => {
|
||||
const parsed = v
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
mark("adapterConfig", "scopes", parsed.length > 0 ? parsed : undefined);
|
||||
}}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="operator.admin"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Wait timeout (ms)">
|
||||
<DraftInput
|
||||
value={eff("adapterConfig", "waitTimeoutMs", String(config.waitTimeoutMs ?? "120000"))}
|
||||
onCommit={(v) => {
|
||||
const parsed = Number.parseInt(v.trim(), 10);
|
||||
mark(
|
||||
"adapterConfig",
|
||||
"waitTimeoutMs",
|
||||
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined,
|
||||
);
|
||||
}}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="120000"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Device auth">
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals
|
||||
remain stable across runs.
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import { buildOpenClawGatewayConfig } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||
import { OpenClawGatewayConfigFields } from "./config-fields";
|
||||
|
||||
export const openClawGatewayUIAdapter: UIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
label: "OpenClaw Gateway",
|
||||
parseStdoutLine: parseOpenClawGatewayStdoutLine,
|
||||
ConfigFields: OpenClawGatewayConfigFields,
|
||||
buildAdapterConfig: buildOpenClawGatewayConfig,
|
||||
};
|
||||
@@ -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 OpenCodeLocalConfigFields({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseOpenCodeStdoutLine } from "@paperclipai/adapter-opencode-local/ui";
|
||||
import { OpenCodeLocalConfigFields } from "./config-fields";
|
||||
import { buildOpenCodeLocalConfig } from "@paperclipai/adapter-opencode-local/ui";
|
||||
|
||||
export const openCodeLocalUIAdapter: UIAdapterModule = {
|
||||
type: "opencode_local",
|
||||
label: "OpenCode (local)",
|
||||
parseStdoutLine: parseOpenCodeStdoutLine,
|
||||
ConfigFields: OpenCodeLocalConfigFields,
|
||||
buildAdapterConfig: buildOpenCodeLocalConfig,
|
||||
};
|
||||
@@ -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 PiLocalConfigFields({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parsePiStdoutLine } from "@paperclipai/adapter-pi-local/ui";
|
||||
import { PiLocalConfigFields } from "./config-fields";
|
||||
import { buildPiLocalConfig } from "@paperclipai/adapter-pi-local/ui";
|
||||
|
||||
export const piLocalUIAdapter: UIAdapterModule = {
|
||||
type: "pi_local",
|
||||
label: "Pi (local)",
|
||||
parseStdoutLine: parsePiStdoutLine,
|
||||
ConfigFields: PiLocalConfigFields,
|
||||
buildAdapterConfig: buildPiLocalConfig,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CreateConfigValues } from "../../components/AgentConfigForm";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function buildProcessConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.args) ac.args = parseCommaArgs(v.args);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
|
||||
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 formatArgList(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join(", ");
|
||||
}
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function ProcessConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<Field label="Command" hint={help.command}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.command
|
||||
: eff("adapterConfig", "command", String(config.command ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ command: v })
|
||||
: mark("adapterConfig", "command", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="e.g. node, python"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Args (comma-separated)" hint={help.args}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.args
|
||||
: eff("adapterConfig", "args", formatArgList(config.args))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ args: v })
|
||||
: mark(
|
||||
"adapterConfig",
|
||||
"args",
|
||||
v ? parseCommaArgs(v) : undefined,
|
||||
)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="e.g. script.js, --flag"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseProcessStdoutLine } from "./parse-stdout";
|
||||
import { ProcessConfigFields } from "./config-fields";
|
||||
import { buildProcessConfig } from "./build-config";
|
||||
|
||||
export const processUIAdapter: UIAdapterModule = {
|
||||
type: "process",
|
||||
label: "Shell Process",
|
||||
parseStdoutLine: parseProcessStdoutLine,
|
||||
ConfigFields: ProcessConfigFields,
|
||||
buildAdapterConfig: buildProcessConfig,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { TranscriptEntry } from "../types";
|
||||
|
||||
export function parseProcessStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { 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,
|
||||
openCodeLocalUIAdapter,
|
||||
piLocalUIAdapter,
|
||||
cursorLocalUIAdapter,
|
||||
openClawGatewayUIAdapter,
|
||||
processUIAdapter,
|
||||
httpUIAdapter,
|
||||
];
|
||||
|
||||
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||
uiAdapters.map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getUIAdapter(type: string): UIAdapterModule {
|
||||
return adaptersByType.get(type) ?? processUIAdapter;
|
||||
}
|
||||
|
||||
export function listUIAdapters(): UIAdapterModule[] {
|
||||
return [...uiAdapters];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
|
||||
import type { TranscriptEntry, StdoutLineParser } from "./types";
|
||||
|
||||
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
|
||||
|
||||
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
|
||||
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
|
||||
const last = entries[entries.length - 1];
|
||||
if (last && last.kind === entry.kind && last.delta) {
|
||||
last.text += entry.text;
|
||||
last.ts = entry.ts;
|
||||
return;
|
||||
}
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
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: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
|
||||
continue;
|
||||
}
|
||||
if (chunk.stream === "system") {
|
||||
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const combined = stdoutBuffer + chunk.chunk;
|
||||
const lines = combined.split(/\r?\n/);
|
||||
stdoutBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = stdoutBuffer.trim();
|
||||
if (trailing) {
|
||||
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
||||
appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
// Re-export shared types so local consumers don't need to change imports
|
||||
export type { TranscriptEntry, StdoutLineParser, CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
export interface AdapterConfigFieldsProps {
|
||||
mode: "create" | "edit";
|
||||
isCreate: boolean;
|
||||
adapterType: string;
|
||||
/** Create mode: raw form values */
|
||||
values: CreateConfigValues | null;
|
||||
/** Create mode: setter for form values */
|
||||
set: ((patch: Partial<CreateConfigValues>) => void) | null;
|
||||
/** Edit mode: original adapterConfig from agent */
|
||||
config: Record<string, unknown>;
|
||||
/** Edit mode: read effective value */
|
||||
eff: <T>(group: "adapterConfig", field: string, original: T) => T;
|
||||
/** Edit mode: mark field dirty */
|
||||
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 {
|
||||
type: string;
|
||||
label: string;
|
||||
parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
|
||||
ConfigFields: ComponentType<AdapterConfigFieldsProps>;
|
||||
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
type InviteSummary = {
|
||||
id: string;
|
||||
companyId: string | null;
|
||||
inviteType: "company_join" | "bootstrap_ceo";
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
expiresAt: string;
|
||||
onboardingPath?: string;
|
||||
onboardingUrl?: string;
|
||||
onboardingTextPath?: string;
|
||||
onboardingTextUrl?: string;
|
||||
skillIndexPath?: string;
|
||||
skillIndexUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
};
|
||||
|
||||
type AcceptInviteInput =
|
||||
| { requestType: "human" }
|
||||
| {
|
||||
requestType: "agent";
|
||||
agentName: string;
|
||||
adapterType?: AgentAdapterType;
|
||||
capabilities?: string | null;
|
||||
agentDefaultsPayload?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type AgentJoinRequestAccepted = JoinRequest & {
|
||||
claimSecret: string;
|
||||
claimApiKeyPath: string;
|
||||
onboarding?: Record<string, unknown>;
|
||||
diagnostics?: Array<{
|
||||
code: string;
|
||||
level: "info" | "warn";
|
||||
message: string;
|
||||
hint?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type InviteOnboardingManifest = {
|
||||
invite: InviteSummary;
|
||||
onboarding: {
|
||||
inviteMessage?: string | null;
|
||||
connectivity?: {
|
||||
guidance?: string;
|
||||
connectionCandidates?: string[];
|
||||
testResolutionEndpoint?: {
|
||||
method?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
textInstructions?: {
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type BoardClaimStatus = {
|
||||
status: "available" | "claimed" | "expired";
|
||||
requiresSignIn: boolean;
|
||||
expiresAt: string | null;
|
||||
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";
|
||||
onboardingTextPath?: string;
|
||||
onboardingTextUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
};
|
||||
|
||||
export const accessApi = {
|
||||
createCompanyInvite: (
|
||||
companyId: string,
|
||||
input: {
|
||||
allowedJoinTypes?: "human" | "agent" | "both";
|
||||
defaultsPayload?: Record<string, unknown> | null;
|
||||
agentMessage?: string | null;
|
||||
} = {},
|
||||
) =>
|
||||
api.post<CompanyInviteCreated>(`/companies/${companyId}/invites`, input),
|
||||
|
||||
createOpenClawInvitePrompt: (
|
||||
companyId: string,
|
||||
input: {
|
||||
agentMessage?: string | null;
|
||||
} = {},
|
||||
) =>
|
||||
api.post<CompanyInviteCreated>(
|
||||
`/companies/${companyId}/openclaw/invite-prompt`,
|
||||
input,
|
||||
),
|
||||
|
||||
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
|
||||
getInviteOnboarding: (token: string) =>
|
||||
api.get<InviteOnboardingManifest>(`/invites/${token}/onboarding`),
|
||||
|
||||
acceptInvite: (token: string, input: AcceptInviteInput) =>
|
||||
api.post<AgentJoinRequestAccepted | JoinRequest | { bootstrapAccepted: true; userId: string }>(
|
||||
`/invites/${token}/accept`,
|
||||
input,
|
||||
),
|
||||
|
||||
listJoinRequests: (companyId: string, status: "pending_approval" | "approved" | "rejected" = "pending_approval") =>
|
||||
api.get<JoinRequest[]>(`/companies/${companyId}/join-requests?status=${status}`),
|
||||
|
||||
approveJoinRequest: (companyId: string, requestId: string) =>
|
||||
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
|
||||
|
||||
rejectJoinRequest: (companyId: string, requestId: string) =>
|
||||
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/reject`, {}),
|
||||
|
||||
claimJoinRequestApiKey: (requestId: string, claimSecret: string) =>
|
||||
api.post<{ keyId: string; token: string; agentId: string; createdAt: string }>(
|
||||
`/join-requests/${requestId}/claim-api-key`,
|
||||
{ claimSecret },
|
||||
),
|
||||
|
||||
getBoardClaimStatus: (token: string, code: string) =>
|
||||
api.get<BoardClaimStatus>(`/board-claim/${token}?code=${encodeURIComponent(code)}`),
|
||||
|
||||
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 }),
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface RunForIssue {
|
||||
runId: string;
|
||||
status: string;
|
||||
agentId: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
createdAt: string;
|
||||
invocationSource: string;
|
||||
usageJson: Record<string, unknown> | null;
|
||||
resultJson: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface IssueForRun {
|
||||
issueId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export const activityApi = {
|
||||
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`),
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
Agent,
|
||||
AgentDetail,
|
||||
AgentInstructionsBundle,
|
||||
AgentInstructionsFileDetail,
|
||||
AgentSkillSnapshot,
|
||||
AdapterEnvironmentTestResult,
|
||||
AgentKeyCreated,
|
||||
AgentRuntimeState,
|
||||
AgentTaskSession,
|
||||
HeartbeatRun,
|
||||
Approval,
|
||||
AgentConfigRevision,
|
||||
} from "@paperclipai/shared";
|
||||
import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import { ApiError, api } from "./client";
|
||||
|
||||
export interface AgentKey {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface AdapterModel {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ClaudeLoginResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
loginUrl: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface OrgNode {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
reports: OrgNode[];
|
||||
}
|
||||
|
||||
export interface AgentHireResponse {
|
||||
agent: Agent;
|
||||
approval: Approval | null;
|
||||
}
|
||||
|
||||
export interface AgentPermissionUpdate {
|
||||
canCreateAgents: boolean;
|
||||
canAssignTasks: boolean;
|
||||
}
|
||||
|
||||
function withCompanyScope(path: string, companyId?: string) {
|
||||
if (!companyId) return path;
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
return `${path}${separator}companyId=${encodeURIComponent(companyId)}`;
|
||||
}
|
||||
|
||||
function agentPath(id: string, companyId?: string, suffix = "") {
|
||||
return withCompanyScope(`/agents/${encodeURIComponent(id)}${suffix}`, companyId);
|
||||
}
|
||||
|
||||
export const agentsApi = {
|
||||
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
|
||||
org: (companyId: string) => api.get<OrgNode[]>(`/companies/${companyId}/org`),
|
||||
listConfigurations: (companyId: string) =>
|
||||
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
|
||||
get: async (id: string, companyId?: string) => {
|
||||
try {
|
||||
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.
|
||||
if (
|
||||
!(error instanceof ApiError) ||
|
||||
error.status !== 409 ||
|
||||
!companyId ||
|
||||
isUuidLike(id)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const urlKey = normalizeAgentUrlKey(id);
|
||||
if (!urlKey) throw error;
|
||||
|
||||
const agents = await api.get<Agent[]>(`/companies/${companyId}/agents`);
|
||||
const matches = agents.filter(
|
||||
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
|
||||
);
|
||||
if (matches.length !== 1) throw error;
|
||||
return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId));
|
||||
}
|
||||
},
|
||||
getConfiguration: (id: string, companyId?: string) =>
|
||||
api.get<Record<string, unknown>>(agentPath(id, companyId, "/configuration")),
|
||||
listConfigRevisions: (id: string, companyId?: string) =>
|
||||
api.get<AgentConfigRevision[]>(agentPath(id, companyId, "/config-revisions")),
|
||||
getConfigRevision: (id: string, revisionId: string, companyId?: string) =>
|
||||
api.get<AgentConfigRevision>(agentPath(id, companyId, `/config-revisions/${revisionId}`)),
|
||||
rollbackConfigRevision: (id: string, revisionId: string, companyId?: string) =>
|
||||
api.post<Agent>(agentPath(id, companyId, `/config-revisions/${revisionId}/rollback`), {}),
|
||||
create: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<Agent>(`/companies/${companyId}/agents`, data),
|
||||
hire: (companyId: string, data: Record<string, unknown>) =>
|
||||
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: 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) =>
|
||||
api.delete<{ ok: true }>(agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`)),
|
||||
runtimeState: (id: string, companyId?: string) =>
|
||||
api.get<AgentRuntimeState>(agentPath(id, companyId, "/runtime-state")),
|
||||
taskSessions: (id: string, companyId?: string) =>
|
||||
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
||||
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
||||
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
||||
adapterModels: (companyId: string, type: string) =>
|
||||
api.get<AdapterModel[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||
),
|
||||
testEnvironment: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
data: { adapterConfig: Record<string, unknown> },
|
||||
) =>
|
||||
api.post<AdapterEnvironmentTestResult>(
|
||||
`/companies/${companyId}/adapters/${type}/test-environment`,
|
||||
data,
|
||||
),
|
||||
invoke: (id: string, companyId?: string) => api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), {}),
|
||||
wakeup: (
|
||||
id: string,
|
||||
data: {
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
triggerDetail?: "manual" | "ping" | "callback" | "system";
|
||||
reason?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
idempotencyKey?: string | null;
|
||||
},
|
||||
companyId?: string,
|
||||
) => 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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Approval, ApprovalComment, Issue } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const approvalsApi = {
|
||||
list: (companyId: string, status?: string) =>
|
||||
api.get<Approval[]>(
|
||||
`/companies/${companyId}/approvals${status ? `?status=${encodeURIComponent(status)}` : ""}`,
|
||||
),
|
||||
create: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<Approval>(`/companies/${companyId}/approvals`, data),
|
||||
get: (id: string) => api.get<Approval>(`/approvals/${id}`),
|
||||
approve: (id: string, decisionNote?: string) =>
|
||||
api.post<Approval>(`/approvals/${id}/approve`, { decisionNote }),
|
||||
reject: (id: string, decisionNote?: string) =>
|
||||
api.post<Approval>(`/approvals/${id}/reject`, { decisionNote }),
|
||||
requestRevision: (id: string, decisionNote?: string) =>
|
||||
api.post<Approval>(`/approvals/${id}/request-revision`, { decisionNote }),
|
||||
resubmit: (id: string, payload?: Record<string, unknown>) =>
|
||||
api.post<Approval>(`/approvals/${id}/resubmit`, { payload }),
|
||||
listComments: (id: string) => api.get<ApprovalComment[]>(`/approvals/${id}/comments`),
|
||||
addComment: (id: string, body: string) =>
|
||||
api.post<ApprovalComment>(`/approvals/${id}/comments`, { body }),
|
||||
listIssues: (id: string) => api.get<Issue[]>(`/approvals/${id}/issues`),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AssetImage } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const assetsApi = {
|
||||
uploadImage: async (companyId: string, file: File, namespace?: string) => {
|
||||
// Read file data into memory eagerly so the fetch body is self-contained.
|
||||
// Clipboard-paste File objects reference transient data that the browser may
|
||||
// discard after the paste-event handler returns, causing ERR_ACCESS_DENIED
|
||||
// when fetch() later tries to stream the FormData body.
|
||||
const buffer = await file.arrayBuffer();
|
||||
const safeFile = new File([buffer], file.name, { type: file.type });
|
||||
|
||||
const form = new FormData();
|
||||
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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
export type AuthSession = {
|
||||
session: { id: string; userId: string };
|
||||
user: { id: string; email: string | null; name: string | null };
|
||||
};
|
||||
|
||||
function toSession(value: unknown): AuthSession | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const sessionValue = record.session;
|
||||
const userValue = record.user;
|
||||
if (!sessionValue || typeof sessionValue !== "object") return null;
|
||||
if (!userValue || typeof userValue !== "object") return null;
|
||||
const session = sessionValue as Record<string, unknown>;
|
||||
const user = userValue as Record<string, unknown>;
|
||||
if (typeof session.id !== "string" || typeof session.userId !== "string") return null;
|
||||
if (typeof user.id !== "string") return null;
|
||||
return {
|
||||
session: { id: session.id, userId: session.userId },
|
||||
user: {
|
||||
id: user.id,
|
||||
email: typeof user.email === "string" ? user.email : null,
|
||||
name: typeof user.name === "string" ? user.name : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function authPost(path: string, body: Record<string, unknown>) {
|
||||
const res = await fetch(`/api/auth${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(payload as { error?: { message?: string } | string } | null)?.error &&
|
||||
typeof (payload as { error?: { message?: string } | string }).error === "object"
|
||||
? ((payload as { error?: { message?: string } }).error?.message ?? `Request failed: ${res.status}`)
|
||||
: (payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
getSession: async (): Promise<AuthSession | null> => {
|
||||
const res = await fetch("/api/auth/get-session", {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (res.status === 401) return null;
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load session (${res.status})`);
|
||||
}
|
||||
const direct = toSession(payload);
|
||||
if (direct) return direct;
|
||||
const nested = payload && typeof payload === "object" ? toSession((payload as Record<string, unknown>).data) : null;
|
||||
return nested;
|
||||
},
|
||||
|
||||
signInEmail: async (input: { email: string; password: string }) => {
|
||||
await authPost("/sign-in/email", input);
|
||||
},
|
||||
|
||||
signUpEmail: async (input: { name: string; email: string; password: string }) => {
|
||||
await authPost("/sign-up/email", input);
|
||||
},
|
||||
|
||||
signOut: async () => {
|
||||
await authPost("/sign-out", {});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
const BASE = "/api";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: unknown;
|
||||
|
||||
constructor(message: string, status: number, body: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers ?? undefined);
|
||||
const body = init?.body;
|
||||
if (!(body instanceof FormData) && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers,
|
||||
credentials: "include",
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => null);
|
||||
throw new ApiError(
|
||||
(errorBody as { error?: string } | null)?.error ?? `Request failed: ${res.status}`,
|
||||
res.status,
|
||||
errorBody,
|
||||
);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
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" }),
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type {
|
||||
Company,
|
||||
CompanyPortabilityExportRequest,
|
||||
CompanyPortabilityExportPreviewResult,
|
||||
CompanyPortabilityExportResult,
|
||||
CompanyPortabilityImportRequest,
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityPreviewRequest,
|
||||
CompanyPortabilityPreviewResult,
|
||||
UpdateCompanyBranding,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export type CompanyStats = Record<string, { agentCount: number; issueCount: number }>;
|
||||
|
||||
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;
|
||||
}) =>
|
||||
api.post<Company>("/companies", data),
|
||||
update: (
|
||||
companyId: string,
|
||||
data: Partial<
|
||||
Pick<
|
||||
Company,
|
||||
"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: 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) =>
|
||||
api.post<CompanyPortabilityImportResult>("/companies/import", data),
|
||||
};
|
||||
@@ -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`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
CostSummary,
|
||||
CostByAgent,
|
||||
CostByProviderModel,
|
||||
CostByBiller,
|
||||
CostByAgentModel,
|
||||
CostByProject,
|
||||
CostWindowSpendRow,
|
||||
FinanceSummary,
|
||||
FinanceByBiller,
|
||||
FinanceByKind,
|
||||
FinanceEvent,
|
||||
ProviderQuotaResult,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
function dateParams(from?: string, to?: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.set("from", from);
|
||||
if (to) params.set("to", to);
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export const costsApi = {
|
||||
summary: (companyId: string, from?: string, to?: string) =>
|
||||
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}` : "";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { DashboardSummary } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const dashboardApi = {
|
||||
summary: (companyId: string) => api.get<DashboardSummary>(`/companies/${companyId}/dashboard`),
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const executionWorkspacesApi = {
|
||||
list: (
|
||||
companyId: string,
|
||||
filters?: {
|
||||
projectId?: string;
|
||||
projectWorkspaceId?: string;
|
||||
issueId?: string;
|
||||
status?: string;
|
||||
reuseEligible?: boolean;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||
if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId);
|
||||
if (filters?.issueId) params.set("issueId", filters.issueId);
|
||||
if (filters?.status) params.set("status", filters.status);
|
||||
if (filters?.reuseEligible) params.set("reuseEligible", "true");
|
||||
const qs = params.toString();
|
||||
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Goal } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const goalsApi = {
|
||||
list: (companyId: string) => api.get<Goal[]>(`/companies/${companyId}/goals`),
|
||||
get: (id: string) => api.get<Goal>(`/goals/${id}`),
|
||||
create: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<Goal>(`/companies/${companyId}/goals`, data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<Goal>(`/goals/${id}`, data),
|
||||
remove: (id: string) => api.delete<Goal>(`/goals/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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 = {
|
||||
get: async (): Promise<HealthStatus> => {
|
||||
const res = await fetch("/api/health", {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null) as { error?: string } | null;
|
||||
throw new Error(payload?.error ?? `Failed to load health (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
HeartbeatRun,
|
||||
HeartbeatRunEvent,
|
||||
InstanceSchedulerHeartbeatAgent,
|
||||
WorkspaceOperation,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface ActiveRunForIssue extends HeartbeatRun {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
}
|
||||
|
||||
export interface LiveRunForIssue {
|
||||
id: string;
|
||||
status: string;
|
||||
invocationSource: string;
|
||||
triggerDetail: string | null;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
createdAt: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
issueId?: string | null;
|
||||
}
|
||||
|
||||
export const heartbeatsApi = {
|
||||
list: (companyId: string, agentId?: string, limit?: number) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (agentId) searchParams.set("agentId", agentId);
|
||||
if (limit) searchParams.set("limit", String(limit));
|
||||
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))}`,
|
||||
),
|
||||
log: (runId: string, offset = 0, limitBytes = 256000) =>
|
||||
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`),
|
||||
activeRunForIssue: (issueId: string) =>
|
||||
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"),
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
export { api } from "./client";
|
||||
export { authApi } from "./auth";
|
||||
export { healthApi } from "./health";
|
||||
export { accessApi } from "./access";
|
||||
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";
|
||||
@@ -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),
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
Approval,
|
||||
DocumentRevision,
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
IssueWorkProduct,
|
||||
UpsertIssueDocument,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const issuesApi = {
|
||||
list: (
|
||||
companyId: string,
|
||||
filters?: {
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
assigneeAgentId?: string;
|
||||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
labelId?: string;
|
||||
originKind?: string;
|
||||
originId?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
q?: string;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
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?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
||||
if (filters?.labelId) params.set("labelId", filters.labelId);
|
||||
if (filters?.originKind) params.set("originKind", filters.originKind);
|
||||
if (filters?.originId) params.set("originId", filters.originId);
|
||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||
if (filters?.q) params.set("q", filters.q);
|
||||
const qs = params.toString();
|
||||
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
listLabels: (companyId: string) => api.get<IssueLabel[]>(`/companies/${companyId}/labels`),
|
||||
createLabel: (companyId: string, data: { name: string; color: string }) =>
|
||||
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
|
||||
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`, {}),
|
||||
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),
|
||||
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
|
||||
checkout: (id: string, agentId: string) =>
|
||||
api.post<Issue>(`/issues/${id}/checkout`, {
|
||||
agentId,
|
||||
expectedStatuses: ["todo", "backlog", "blocked"],
|
||||
}),
|
||||
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
|
||||
listComments: (id: string) => api.get<IssueComment[]>(`/issues/${id}/comments`),
|
||||
addComment: (id: string, body: string, reopen?: boolean, interrupt?: boolean) =>
|
||||
api.post<IssueComment>(
|
||||
`/issues/${id}/comments`,
|
||||
{
|
||||
body,
|
||||
...(reopen === undefined ? {} : { reopen }),
|
||||
...(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,
|
||||
issueId: string,
|
||||
file: File,
|
||||
issueCommentId?: string | null,
|
||||
) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (issueCommentId) {
|
||||
form.append("issueCommentId", issueCommentId);
|
||||
}
|
||||
return api.postForm<IssueAttachment>(`/companies/${companyId}/issues/${issueId}/attachments`, form);
|
||||
},
|
||||
deleteAttachment: (id: string) => api.delete<{ ok: true }>(`/attachments/${id}`),
|
||||
listApprovals: (id: string) => api.get<Approval[]>(`/issues/${id}/approvals`),
|
||||
linkApproval: (id: string, approvalId: string) =>
|
||||
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}`),
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Project, ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
function withCompanyScope(path: string, companyId?: string) {
|
||||
if (!companyId) return path;
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
return `${path}${separator}companyId=${encodeURIComponent(companyId)}`;
|
||||
}
|
||||
|
||||
function projectPath(id: string, companyId?: string, suffix = "") {
|
||||
return withCompanyScope(`/projects/${encodeURIComponent(id)}${suffix}`, companyId);
|
||||
}
|
||||
|
||||
export const projectsApi = {
|
||||
list: (companyId: string) => api.get<Project[]>(`/companies/${companyId}/projects`),
|
||||
get: (id: string, companyId?: string) => api.get<Project>(projectPath(id, companyId)),
|
||||
create: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<Project>(`/companies/${companyId}/projects`, data),
|
||||
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
|
||||
api.patch<Project>(projectPath(id, companyId), data),
|
||||
listWorkspaces: (projectId: string, companyId?: string) =>
|
||||
api.get<ProjectWorkspace[]>(projectPath(projectId, companyId, "/workspaces")),
|
||||
createWorkspace: (projectId: string, data: Record<string, unknown>, companyId?: string) =>
|
||||
api.post<ProjectWorkspace>(projectPath(projectId, companyId, "/workspaces"), data),
|
||||
updateWorkspace: (projectId: string, workspaceId: string, data: Record<string, unknown>, companyId?: string) =>
|
||||
api.patch<ProjectWorkspace>(
|
||||
projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`),
|
||||
data,
|
||||
),
|
||||
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)),
|
||||
};
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CompanySecret, SecretProviderDescriptor, SecretProvider } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const secretsApi = {
|
||||
list: (companyId: string) => api.get<CompanySecret[]>(`/companies/${companyId}/secrets`),
|
||||
providers: (companyId: string) =>
|
||||
api.get<SecretProviderDescriptor[]>(`/companies/${companyId}/secret-providers`),
|
||||
create: (
|
||||
companyId: string,
|
||||
data: {
|
||||
name: string;
|
||||
value: string;
|
||||
provider?: SecretProvider;
|
||||
description?: string | null;
|
||||
externalRef?: string | null;
|
||||
},
|
||||
) => api.post<CompanySecret>(`/companies/${companyId}/secrets`, data),
|
||||
rotate: (id: string, data: { value: string; externalRef?: string | null }) =>
|
||||
api.post<CompanySecret>(`/secrets/${id}/rotate`, data),
|
||||
update: (
|
||||
id: string,
|
||||
data: { name?: string; description?: string | null; externalRef?: string | null },
|
||||
) => api.patch<CompanySecret>(`/secrets/${id}`, data),
|
||||
remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { SidebarBadges } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const sidebarBadgesApi = {
|
||||
get: (companyId: string) => api.get<SidebarBadges>(`/companies/${companyId}/sidebar-badges`),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import { issuesApi } from "../api/issues";
|
||||
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";
|
||||
|
||||
const MIN_DASHBOARD_RUNS = 4;
|
||||
|
||||
function isRunActive(run: LiveRunForIssue): boolean {
|
||||
return run.status === "queued" || run.status === "running";
|
||||
}
|
||||
|
||||
interface ActiveAgentsPanelProps {
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: [...queryKeys.liveRuns(companyId), "dashboard"],
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS),
|
||||
});
|
||||
|
||||
const runs = liveRuns ?? [];
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(companyId),
|
||||
queryFn: () => issuesApi.list(companyId),
|
||||
enabled: runs.length > 0,
|
||||
});
|
||||
|
||||
const issueById = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
for (const issue of issues ?? []) {
|
||||
map.set(issue.id, issue);
|
||||
}
|
||||
return map;
|
||||
}, [issues]);
|
||||
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||
runs,
|
||||
companyId,
|
||||
maxChunksPerRun: 120,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Agents
|
||||
</h3>
|
||||
{runs.length === 0 ? (
|
||||
<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 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}
|
||||
transcript={transcriptByRun.get(run.id) ?? []}
|
||||
hasOutput={hasOutputForRun(run.id)}
|
||||
isActive={isRunActive(run)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentRunCard({
|
||||
run,
|
||||
issue,
|
||||
transcript,
|
||||
hasOutput,
|
||||
isActive,
|
||||
}: {
|
||||
run: LiveRunForIssue;
|
||||
issue?: Issue;
|
||||
transcript: TranscriptEntry[];
|
||||
hasOutput: boolean;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex h-[320px] flex-col overflow-hidden rounded-xl border shadow-sm",
|
||||
isActive
|
||||
? "border-cyan-500/25 bg-cyan-500/[0.04] shadow-[0_16px_40px_rgba(6,182,212,0.08)]"
|
||||
: "border-border bg-background/70",
|
||||
)}>
|
||||
<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>
|
||||
|
||||
<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 py-1 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { HeartbeatRun } from "@paperclipai/shared";
|
||||
|
||||
/* ---- Utilities ---- */
|
||||
|
||||
export function getLast14Days(): string[] {
|
||||
return Array.from({ length: 14 }, (_, i) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - (13 - i));
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
}
|
||||
|
||||
function formatDayLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T12:00:00");
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
/* ---- Sub-components ---- */
|
||||
|
||||
function DateLabels({ days }: { days: string[] }) {
|
||||
return (
|
||||
<div className="flex gap-[3px] mt-1.5">
|
||||
{days.map((day, i) => (
|
||||
<div key={day} className="flex-1 text-center">
|
||||
{(i === 0 || i === 6 || i === 13) ? (
|
||||
<span className="text-[9px] text-muted-foreground tabular-nums">{formatDayLabel(day)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend({ items }: { items: { color: string; label: string }[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-2.5 gap-y-0.5 mt-2">
|
||||
{items.map(item => (
|
||||
<span key={item.label} className="flex items-center gap-1 text-[9px] text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground">{title}</h3>
|
||||
{subtitle && <span className="text-[10px] text-muted-foreground/60">{subtitle}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Chart Components ---- */
|
||||
|
||||
export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
|
||||
const days = getLast14Days();
|
||||
|
||||
const grouped = new Map<string, { succeeded: number; failed: number; other: number }>();
|
||||
for (const day of days) grouped.set(day, { succeeded: 0, failed: 0, other: 0 });
|
||||
for (const run of runs) {
|
||||
const day = new Date(run.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
if (run.status === "succeeded") entry.succeeded++;
|
||||
else if (run.status === "failed" || run.status === "timed_out") entry.failed++;
|
||||
else entry.other++;
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...Array.from(grouped.values()).map(v => v.succeeded + v.failed + v.other), 1);
|
||||
const hasData = Array.from(grouped.values()).some(v => v.succeeded + v.failed + v.other > 0);
|
||||
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const total = entry.succeeded + entry.failed + entry.other;
|
||||
const heightPct = (total / maxValue) * 100;
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} runs`}>
|
||||
{total > 0 ? (
|
||||
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
|
||||
{entry.succeeded > 0 && <div className="bg-emerald-500" style={{ flex: entry.succeeded }} />}
|
||||
{entry.failed > 0 && <div className="bg-red-500" style={{ flex: entry.failed }} />}
|
||||
{entry.other > 0 && <div className="bg-neutral-500" style={{ flex: entry.other }} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: "#ef4444",
|
||||
high: "#f97316",
|
||||
medium: "#eab308",
|
||||
low: "#6b7280",
|
||||
};
|
||||
|
||||
const priorityOrder = ["critical", "high", "medium", "low"] as const;
|
||||
|
||||
export function PriorityChart({ issues }: { issues: { priority: string; createdAt: Date }[] }) {
|
||||
const days = getLast14Days();
|
||||
const grouped = new Map<string, Record<string, number>>();
|
||||
for (const day of days) grouped.set(day, { critical: 0, high: 0, medium: 0, low: 0 });
|
||||
for (const issue of issues) {
|
||||
const day = new Date(issue.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
if (issue.priority in entry) entry[issue.priority]++;
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1);
|
||||
const hasData = Array.from(grouped.values()).some(v => Object.values(v).reduce((a, b) => a + b, 0) > 0);
|
||||
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No issues</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const total = Object.values(entry).reduce((a, b) => a + b, 0);
|
||||
const heightPct = (total / maxValue) * 100;
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
|
||||
{total > 0 ? (
|
||||
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
|
||||
{priorityOrder.map(p => entry[p] > 0 ? (
|
||||
<div key={p} style={{ flex: entry[p], backgroundColor: priorityColors[p] }} />
|
||||
) : null)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
<ChartLegend items={priorityOrder.map(p => ({ color: priorityColors[p], label: p.charAt(0).toUpperCase() + p.slice(1) }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
todo: "#3b82f6",
|
||||
in_progress: "#8b5cf6",
|
||||
in_review: "#a855f7",
|
||||
done: "#10b981",
|
||||
blocked: "#ef4444",
|
||||
cancelled: "#6b7280",
|
||||
backlog: "#64748b",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
todo: "To Do",
|
||||
in_progress: "In Progress",
|
||||
in_review: "In Review",
|
||||
done: "Done",
|
||||
blocked: "Blocked",
|
||||
cancelled: "Cancelled",
|
||||
backlog: "Backlog",
|
||||
};
|
||||
|
||||
export function IssueStatusChart({ issues }: { issues: { status: string; createdAt: Date }[] }) {
|
||||
const days = getLast14Days();
|
||||
const allStatuses = new Set<string>();
|
||||
const grouped = new Map<string, Record<string, number>>();
|
||||
for (const day of days) grouped.set(day, {});
|
||||
for (const issue of issues) {
|
||||
const day = new Date(issue.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
entry[issue.status] = (entry[issue.status] ?? 0) + 1;
|
||||
allStatuses.add(issue.status);
|
||||
}
|
||||
|
||||
const statusOrder = ["todo", "in_progress", "in_review", "done", "blocked", "cancelled", "backlog"].filter(s => allStatuses.has(s));
|
||||
const maxValue = Math.max(...Array.from(grouped.values()).map(v => Object.values(v).reduce((a, b) => a + b, 0)), 1);
|
||||
const hasData = allStatuses.size > 0;
|
||||
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No issues</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const total = Object.values(entry).reduce((a, b) => a + b, 0);
|
||||
const heightPct = (total / maxValue) * 100;
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} issues`}>
|
||||
{total > 0 ? (
|
||||
<div className="flex flex-col-reverse gap-px overflow-hidden" style={{ height: `${heightPct}%`, minHeight: 2 }}>
|
||||
{statusOrder.map(s => (entry[s] ?? 0) > 0 ? (
|
||||
<div key={s} style={{ flex: entry[s], backgroundColor: statusColors[s] ?? "#6b7280" }} />
|
||||
) : null)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
<ChartLegend items={statusOrder.map(s => ({ color: statusColors[s] ?? "#6b7280", label: statusLabels[s] ?? s }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) {
|
||||
const days = getLast14Days();
|
||||
const grouped = new Map<string, { succeeded: number; total: number }>();
|
||||
for (const day of days) grouped.set(day, { succeeded: 0, total: 0 });
|
||||
for (const run of runs) {
|
||||
const day = new Date(run.createdAt).toISOString().slice(0, 10);
|
||||
const entry = grouped.get(day);
|
||||
if (!entry) continue;
|
||||
entry.total++;
|
||||
if (run.status === "succeeded") entry.succeeded++;
|
||||
}
|
||||
|
||||
const hasData = Array.from(grouped.values()).some(v => v.total > 0);
|
||||
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-[3px] h-20">
|
||||
{days.map(day => {
|
||||
const entry = grouped.get(day)!;
|
||||
const rate = entry.total > 0 ? entry.succeeded / entry.total : 0;
|
||||
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444";
|
||||
return (
|
||||
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${entry.total > 0 ? Math.round(rate * 100) : 0}% (${entry.succeeded}/${entry.total})`}>
|
||||
{entry.total > 0 ? (
|
||||
<div style={{ height: `${rate * 100}%`, minHeight: 2, backgroundColor: color }} />
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-sm" style={{ height: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DateLabels days={days} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Link } from "@/lib/router";
|
||||
import { Identity } from "./Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn } from "../lib/utils";
|
||||
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
|
||||
|
||||
const ACTION_VERBS: Record<string, string> = {
|
||||
"issue.created": "created",
|
||||
"issue.updated": "updated",
|
||||
"issue.checked_out": "checked out",
|
||||
"issue.released": "released",
|
||||
"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",
|
||||
"agent.updated": "updated",
|
||||
"agent.paused": "paused",
|
||||
"agent.resumed": "resumed",
|
||||
"agent.terminated": "terminated",
|
||||
"agent.key_created": "created API key for",
|
||||
"agent.budget_updated": "updated budget for",
|
||||
"agent.runtime_session_reset": "reset session for",
|
||||
"heartbeat.invoked": "invoked heartbeat for",
|
||||
"heartbeat.cancelled": "cancelled heartbeat for",
|
||||
"approval.created": "requested approval",
|
||||
"approval.approved": "approved",
|
||||
"approval.rejected": "rejected",
|
||||
"project.created": "created",
|
||||
"project.updated": "updated",
|
||||
"project.deleted": "deleted",
|
||||
"goal.created": "created",
|
||||
"goal.updated": "updated",
|
||||
"goal.deleted": "deleted",
|
||||
"cost.reported": "reported cost for",
|
||||
"cost.recorded": "recorded cost for",
|
||||
"company.created": "created company",
|
||||
"company.updated": "updated company",
|
||||
"company.archived": "archived",
|
||||
"company.budget_updated": "updated budget for",
|
||||
};
|
||||
|
||||
function humanizeValue(value: unknown): string {
|
||||
if (typeof value !== "string") return String(value ?? "none");
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function formatVerb(action: string, details?: Record<string, unknown> | null): string {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
if (details.status !== undefined) {
|
||||
const from = previous.status;
|
||||
return from
|
||||
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
|
||||
: `changed status to ${humanizeValue(details.status)} on`;
|
||||
}
|
||||
if (details.priority !== undefined) {
|
||||
const from = previous.priority;
|
||||
return from
|
||||
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
|
||||
: `changed priority to ${humanizeValue(details.priority)} on`;
|
||||
}
|
||||
}
|
||||
return ACTION_VERBS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
|
||||
switch (entityType) {
|
||||
case "issue": return `/issues/${name ?? entityId}`;
|
||||
case "agent": return `/agents/${entityId}`;
|
||||
case "project": return `/projects/${deriveProjectUrlKey(name, entityId)}`;
|
||||
case "goal": return `/goals/${entityId}`;
|
||||
case "approval": return `/approvals/${entityId}`;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ActivityRowProps {
|
||||
event: ActivityEvent;
|
||||
agentMap: Map<string, Agent>;
|
||||
entityNameMap: Map<string, string>;
|
||||
entityTitleMap?: Map<string, string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
||||
const verb = formatVerb(event.action, event.details);
|
||||
|
||||
const isHeartbeatEvent = event.entityType === "heartbeat_run";
|
||||
const heartbeatAgentId = isHeartbeatEvent
|
||||
? (event.details as Record<string, unknown> | null)?.agentId as string | undefined
|
||||
: undefined;
|
||||
|
||||
const name = isHeartbeatEvent
|
||||
? (heartbeatAgentId ? entityNameMap.get(`agent:${heartbeatAgentId}`) : null)
|
||||
: entityNameMap.get(`${event.entityType}:${event.entityId}`);
|
||||
|
||||
const entityTitle = entityTitleMap?.get(`${event.entityType}:${event.entityId}`);
|
||||
|
||||
const link = isHeartbeatEvent && heartbeatAgentId
|
||||
? `/agents/${heartbeatAgentId}/runs/${event.entityId}`
|
||||
: entityLink(event.entityType, event.entityId, name);
|
||||
|
||||
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
|
||||
const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown");
|
||||
|
||||
const inner = (
|
||||
<div className="flex gap-3">
|
||||
<p className="flex-1 min-w-0 truncate">
|
||||
<Identity
|
||||
name={actorName}
|
||||
size="xs"
|
||||
className="align-baseline"
|
||||
/>
|
||||
<span className="text-muted-foreground ml-1">{verb} </span>
|
||||
{name && <span className="font-medium">{name}</span>}
|
||||
{entityTitle && <span className="text-muted-foreground ml-1">— {entityTitle}</span>}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const classes = cn(
|
||||
"px-4 py-2 text-sm",
|
||||
link && "cursor-pointer hover:bg-accent/50 transition-colors",
|
||||
className,
|
||||
);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link to={link} className={cn(classes, "no-underline text-inherit block")}>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclipai/shared";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AGENT_ICONS, getAgentIcon } from "../lib/agent-icons";
|
||||
|
||||
const DEFAULT_ICON: AgentIconName = "bot";
|
||||
|
||||
interface AgentIconProps {
|
||||
icon: string | null | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentIcon({ icon, className }: AgentIconProps) {
|
||||
const Icon = getAgentIcon(icon);
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
|
||||
interface AgentIconPickerProps {
|
||||
value: string | null | undefined;
|
||||
onChange: (icon: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AgentIconPicker({ value, onChange, children }: AgentIconPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const entries = AGENT_ICON_NAMES.map((name) => [name, AGENT_ICONS[name]] as const);
|
||||
if (!search) return entries;
|
||||
const q = search.toLowerCase();
|
||||
return entries.filter(([name]) => name.includes(q));
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-3" align="start">
|
||||
<Input
|
||||
placeholder="Search icons..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="mb-2 h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="grid grid-cols-7 gap-1 max-h-48 overflow-y-auto">
|
||||
{filtered.map(([name, Icon]) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => {
|
||||
onChange(name);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-center h-8 w-8 rounded hover:bg-accent transition-colors",
|
||||
(value ?? DEFAULT_ICON) === name && "bg-accent ring-1 ring-primary"
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="col-span-7 text-xs text-muted-foreground text-center py-2">No icons match</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import { AGENT_ROLE_LABELS, type Agent, type AgentRuntimeState } from "@paperclipai/shared";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { Identity } from "./Identity";
|
||||
import { formatDate, agentUrl } from "../lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface AgentPropertiesProps {
|
||||
agent: Agent;
|
||||
runtimeState?: AgentRuntimeState;
|
||||
}
|
||||
|
||||
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)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!agent.reportsTo,
|
||||
});
|
||||
|
||||
const reportsToAgent = agent.reportsTo ? agents?.find((a) => a.id === agent.reportsTo) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Status">
|
||||
<StatusBadge status={agent.status} />
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Role">
|
||||
<span className="text-sm">{roleLabels[agent.role] ?? agent.role}</span>
|
||||
</PropertyRow>
|
||||
{agent.title && (
|
||||
<PropertyRow label="Title">
|
||||
<span className="text-sm">{agent.title}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Adapter">
|
||||
<span className="text-sm font-mono">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1">
|
||||
{(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId) && (
|
||||
<PropertyRow label="Session">
|
||||
<span className="text-xs font-mono">
|
||||
{String(runtimeState.sessionDisplayId ?? runtimeState.sessionId).slice(0, 12)}...
|
||||
</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{runtimeState?.lastError && (
|
||||
<PropertyRow label="Last error">
|
||||
<span className="text-xs text-red-600 dark:text-red-400 truncate max-w-[160px]">{runtimeState.lastError}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{agent.lastHeartbeatAt && (
|
||||
<PropertyRow label="Last Heartbeat">
|
||||
<span className="text-sm">{formatDate(agent.lastHeartbeatAt)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{agent.reportsTo && (
|
||||
<PropertyRow label="Reports To">
|
||||
{reportsToAgent ? (
|
||||
<Link to={agentUrl(reportsToAgent)} className="hover:underline">
|
||||
<Identity name={reportsToAgent.name} size="sm" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-mono">{agent.reportsTo.slice(0, 8)}</span>
|
||||
)}
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Created">
|
||||
<span className="text-sm">{formatDate(agent.createdAt)}</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Identity } from "./Identity";
|
||||
import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import type { Approval, Agent } from "@paperclipai/shared";
|
||||
|
||||
function statusIcon(status: string) {
|
||||
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />;
|
||||
if (status === "rejected") return <XCircle className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />;
|
||||
if (status === "revision_requested") return <Clock className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />;
|
||||
if (status === "pending") return <Clock className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ApprovalCard({
|
||||
approval,
|
||||
requesterAgent,
|
||||
onApprove,
|
||||
onReject,
|
||||
onOpen,
|
||||
detailLink,
|
||||
isPending,
|
||||
}: {
|
||||
approval: Approval;
|
||||
requesterAgent: Agent | null;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onOpen?: () => void;
|
||||
detailLink?: string;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
{requesterAgent && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
requested by <Identity name={requesterAgent.name} size="sm" className="inline-flex" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{statusIcon(approval.status)}
|
||||
<span className="text-xs text-muted-foreground capitalize">{approval.status}</span>
|
||||
<span className="text-xs text-muted-foreground">· {timeAgo(approval.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payload */}
|
||||
<ApprovalPayloadRenderer type={approval.type} payload={approval.payload} />
|
||||
|
||||
{/* Decision note */}
|
||||
{approval.decisionNote && (
|
||||
<div className="mt-3 text-xs text-muted-foreground italic border-t border-border pt-2">
|
||||
Note: {approval.decisionNote}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{showResolutionButtons && (
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-700 hover:bg-green-600 text-white"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
{detailLink ? (
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" asChild>
|
||||
<Link to={detailLink}>View details</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
||||
View details
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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;
|
||||
|
||||
function PayloadField({ label, value }: { label: string; value: unknown }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs">{label}</span>
|
||||
<span>{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs">Name</span>
|
||||
<span className="font-medium">{String(payload.name ?? "—")}</span>
|
||||
</div>
|
||||
<PayloadField label="Role" value={payload.role} />
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
<PayloadField label="Icon" value={payload.icon} />
|
||||
{!!payload.capabilities && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs pt-0.5">Capabilities</span>
|
||||
<span className="text-muted-foreground">{String(payload.capabilities)}</span>
|
||||
</div>
|
||||
)}
|
||||
{!!payload.adapterType && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-20 sm:w-24 shrink-0 text-xs">Adapter</span>
|
||||
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{String(payload.adapterType)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<SkillList values={payload.desiredSkills} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CeoStrategyPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
const plan = payload.plan ?? payload.description ?? payload.strategy ?? payload.text;
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!plan && (
|
||||
<div className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground whitespace-pre-wrap font-mono text-xs max-h-48 overflow-y-auto">
|
||||
{String(plan)}
|
||||
</div>
|
||||
)}
|
||||
{!plan && (
|
||||
<pre className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground overflow-x-auto max-h-48">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const CHARS = [" ", ".", "·", "▪", "▫", "○"] as const;
|
||||
const TARGET_FPS = 24;
|
||||
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
|
||||
|
||||
const PAPERCLIP_SPRITES = [
|
||||
[
|
||||
" ╭────╮ ",
|
||||
" ╭╯╭──╮│ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ ╰──╯│ ",
|
||||
" ╰─────╯ ",
|
||||
],
|
||||
[
|
||||
" ╭─────╮ ",
|
||||
" │╭──╮╰╮ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" │╰──╯ │ ",
|
||||
" ╰────╯ ",
|
||||
],
|
||||
] as const;
|
||||
|
||||
type PaperclipSprite = (typeof PAPERCLIP_SPRITES)[number];
|
||||
|
||||
interface Clip {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
drift: number;
|
||||
sprite: PaperclipSprite;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function measureChar(container: HTMLElement): { w: number; h: number } {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "M";
|
||||
span.style.cssText =
|
||||
"position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;";
|
||||
container.appendChild(span);
|
||||
const rect = span.getBoundingClientRect();
|
||||
container.removeChild(span);
|
||||
return { w: rect.width, h: rect.height };
|
||||
}
|
||||
|
||||
function spriteSize(sprite: PaperclipSprite): { width: number; height: number } {
|
||||
let width = 0;
|
||||
for (const row of sprite) width = Math.max(width, row.length);
|
||||
return { width, height: sprite.length };
|
||||
}
|
||||
|
||||
export function AsciiArtAnimation() {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preRef.current) return;
|
||||
const preEl: HTMLPreElement = preRef.current;
|
||||
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let isVisible = document.visibilityState !== "hidden";
|
||||
let loopActive = false;
|
||||
let lastRenderAt = 0;
|
||||
let tick = 0;
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let charW = 7;
|
||||
let charH = 11;
|
||||
let trail = new Float32Array(0);
|
||||
let colWave = new Float32Array(0);
|
||||
let rowWave = new Float32Array(0);
|
||||
let clipMask = new Uint16Array(0);
|
||||
let clips: Clip[] = [];
|
||||
let lastOutput = "";
|
||||
|
||||
function toGlyph(value: number): string {
|
||||
const clamped = Math.max(0, Math.min(0.999, value));
|
||||
const idx = Math.floor(clamped * CHARS.length);
|
||||
return CHARS[idx] ?? " ";
|
||||
}
|
||||
|
||||
function rebuildGrid() {
|
||||
const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW)));
|
||||
const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH)));
|
||||
if (nextCols === cols && nextRows === rows) return;
|
||||
|
||||
cols = nextCols;
|
||||
rows = nextRows;
|
||||
const cellCount = cols * rows;
|
||||
trail = new Float32Array(cellCount);
|
||||
colWave = new Float32Array(cols);
|
||||
rowWave = new Float32Array(rows);
|
||||
clipMask = new Uint16Array(cellCount);
|
||||
clips = clips.filter((clip) => {
|
||||
return (
|
||||
clip.x > -clip.width - 2 &&
|
||||
clip.x < cols + 2 &&
|
||||
clip.y > -clip.height - 2 &&
|
||||
clip.y < rows + 2
|
||||
);
|
||||
});
|
||||
lastOutput = "";
|
||||
}
|
||||
|
||||
function drawStaticFrame() {
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
preEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const ambient = (Math.sin(c * 0.11 + r * 0.04) + Math.cos(r * 0.08 - c * 0.02)) * 0.18 + 0.22;
|
||||
grid[r][c] = toGlyph(ambient);
|
||||
}
|
||||
}
|
||||
|
||||
const gapX = 18;
|
||||
const gapY = 13;
|
||||
for (let baseRow = 1; baseRow < rows - 9; baseRow += gapY) {
|
||||
const startX = Math.floor(baseRow / gapY) % 2 === 0 ? 2 : 10;
|
||||
for (let baseCol = startX; baseCol < cols - 10; baseCol += gapX) {
|
||||
const sprite = PAPERCLIP_SPRITES[(baseCol + baseRow) % PAPERCLIP_SPRITES.length]!;
|
||||
for (let sr = 0; sr < sprite.length; sr++) {
|
||||
const line = sprite[sr]!;
|
||||
for (let sc = 0; sc < line.length; sc++) {
|
||||
const ch = line[sc] ?? " ";
|
||||
if (ch === " ") continue;
|
||||
const row = baseRow + sr;
|
||||
const col = baseCol + sc;
|
||||
if (row < 0 || row >= rows || col < 0 || col >= cols) continue;
|
||||
grid[row]![col] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = grid.map((line) => line.join("")).join("\n");
|
||||
preEl.textContent = output;
|
||||
lastOutput = output;
|
||||
}
|
||||
|
||||
function spawnClip() {
|
||||
const sprite = PAPERCLIP_SPRITES[Math.floor(Math.random() * PAPERCLIP_SPRITES.length)]!;
|
||||
const size = spriteSize(sprite);
|
||||
const edge = Math.random();
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
|
||||
if (edge < 0.68) {
|
||||
x = Math.random() < 0.5 ? -size.width - 1 : cols + 1;
|
||||
y = Math.random() * Math.max(1, rows - size.height);
|
||||
vx = x < 0 ? 0.04 + Math.random() * 0.05 : -(0.04 + Math.random() * 0.05);
|
||||
vy = (Math.random() - 0.5) * 0.014;
|
||||
} else {
|
||||
x = Math.random() * Math.max(1, cols - size.width);
|
||||
y = Math.random() < 0.5 ? -size.height - 1 : rows + 1;
|
||||
vx = (Math.random() - 0.5) * 0.014;
|
||||
vy = y < 0 ? 0.028 + Math.random() * 0.034 : -(0.028 + Math.random() * 0.034);
|
||||
}
|
||||
|
||||
clips.push({
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
life: 0,
|
||||
maxLife: 260 + Math.random() * 220,
|
||||
drift: (Math.random() - 0.5) * 1.2,
|
||||
sprite,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
}
|
||||
|
||||
function stampClip(clip: Clip, alpha: number) {
|
||||
const baseCol = Math.round(clip.x);
|
||||
const baseRow = Math.round(clip.y);
|
||||
for (let sr = 0; sr < clip.sprite.length; sr++) {
|
||||
const line = clip.sprite[sr]!;
|
||||
const row = baseRow + sr;
|
||||
if (row < 0 || row >= rows) continue;
|
||||
for (let sc = 0; sc < line.length; sc++) {
|
||||
const ch = line[sc] ?? " ";
|
||||
if (ch === " ") continue;
|
||||
const col = baseCol + sc;
|
||||
if (col < 0 || col >= cols) continue;
|
||||
const idx = row * cols + col;
|
||||
const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92;
|
||||
trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke);
|
||||
clipMask[idx] = ch.charCodeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function step(time: number) {
|
||||
if (!loopActive) return;
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return;
|
||||
|
||||
const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667);
|
||||
lastRenderAt = time;
|
||||
tick += delta;
|
||||
|
||||
const cellCount = cols * rows;
|
||||
const targetCount = Math.max(3, Math.floor(cellCount / 2200));
|
||||
while (clips.length < targetCount) spawnClip();
|
||||
|
||||
for (let i = 0; i < trail.length; i++) trail[i] *= 0.92;
|
||||
clipMask.fill(0);
|
||||
|
||||
for (let i = clips.length - 1; i >= 0; i--) {
|
||||
const clip = clips[i]!;
|
||||
clip.life += delta;
|
||||
|
||||
const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018;
|
||||
const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014;
|
||||
clip.vx = (clip.vx + wobbleX) * 0.998;
|
||||
clip.vy = (clip.vy + wobbleY) * 0.998;
|
||||
|
||||
clip.x += clip.vx * delta;
|
||||
clip.y += clip.vy * delta;
|
||||
|
||||
if (
|
||||
clip.life >= clip.maxLife ||
|
||||
clip.x < -clip.width - 2 ||
|
||||
clip.x > cols + 2 ||
|
||||
clip.y < -clip.height - 2 ||
|
||||
clip.y > rows + 2
|
||||
) {
|
||||
clips.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const life = clip.life / clip.maxLife;
|
||||
const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1;
|
||||
stampClip(clip, alpha);
|
||||
}
|
||||
|
||||
for (let c = 0; c < cols; c++) colWave[c] = Math.sin(c * 0.08 + tick * 0.06);
|
||||
for (let r = 0; r < rows; r++) rowWave[r] = Math.cos(r * 0.1 - tick * 0.05);
|
||||
|
||||
let output = "";
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = r * cols + c;
|
||||
const clipChar = clipMask[idx];
|
||||
if (clipChar > 0) {
|
||||
output += String.fromCharCode(clipChar);
|
||||
continue;
|
||||
}
|
||||
const ambient = (colWave[c] + rowWave[r]) * 0.08 + 0.1;
|
||||
const intensity = Math.max(trail[idx] ?? 0, ambient * 0.45);
|
||||
output += toGlyph(intensity);
|
||||
}
|
||||
if (r < rows - 1) output += "\n";
|
||||
}
|
||||
|
||||
if (output !== lastOutput) {
|
||||
preEl.textContent = output;
|
||||
lastOutput = output;
|
||||
}
|
||||
}
|
||||
|
||||
function syncLoop() {
|
||||
const canRender = cols > 0 && rows > 0;
|
||||
if (motionMedia.matches) {
|
||||
if (loopActive) {
|
||||
loopActive = false;
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
if (canRender) drawStaticFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVisible || !canRender) {
|
||||
if (loopActive) {
|
||||
loopActive = false;
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!loopActive) {
|
||||
loopActive = true;
|
||||
lastRenderAt = 0;
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
const size = measureChar(preEl);
|
||||
charW = size.w;
|
||||
charH = size.h;
|
||||
rebuildGrid();
|
||||
syncLoop();
|
||||
});
|
||||
observer.observe(preEl);
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
isVisible = document.visibilityState !== "hidden";
|
||||
syncLoop();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
const onMotionChange = () => {
|
||||
syncLoop();
|
||||
};
|
||||
motionMedia.addEventListener("change", onMotionChange);
|
||||
|
||||
const charSize = measureChar(preEl);
|
||||
charW = charSize.w;
|
||||
charH = charSize.h;
|
||||
rebuildGrid();
|
||||
syncLoop();
|
||||
|
||||
return () => {
|
||||
loopActive = false;
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
motionMedia.removeEventListener("change", onMotionChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="w-full h-full m-0 p-0 overflow-hidden text-muted-foreground/60 select-none leading-none"
|
||||
style={{ fontSize: "11px", fontFamily: "monospace" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
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();
|
||||
|
||||
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
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="mr-2 shrink-0"
|
||||
onClick={toggleSidebar}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
// 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">
|
||||
{menuButton}
|
||||
<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">
|
||||
{menuButton}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
CircleDot,
|
||||
Bot,
|
||||
Hexagon,
|
||||
Target,
|
||||
LayoutDashboard,
|
||||
Inbox,
|
||||
DollarSign,
|
||||
History,
|
||||
SquarePen,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { agentUrl, projectUrl } from "../lib/utils";
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue, openNewAgent } = useDialog();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const searchQuery = query.trim();
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isMobile, setSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery("");
|
||||
}, [open]);
|
||||
|
||||
const { data: issues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && open,
|
||||
});
|
||||
|
||||
const { data: searchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery }),
|
||||
enabled: !!selectedCompanyId && open && searchQuery.length > 0,
|
||||
});
|
||||
|
||||
const { data: agents = [] } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && open,
|
||||
});
|
||||
|
||||
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);
|
||||
navigate(path);
|
||||
}
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
};
|
||||
|
||||
const visibleIssues = useMemo(
|
||||
() => (searchQuery.length > 0 ? searchedIssues : issues),
|
||||
[issues, searchedIssues, searchQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={(v) => {
|
||||
setOpen(v);
|
||||
if (v && isMobile) setSidebarOpen(false);
|
||||
}}>
|
||||
<CommandInput
|
||||
placeholder="Search issues, agents, projects..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Actions">
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
openNewIssue();
|
||||
}}
|
||||
>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
Create new issue
|
||||
<span className="ml-auto text-xs text-muted-foreground">C</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
openNewAgent();
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create new agent
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/projects")}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create new project
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Pages">
|
||||
<CommandItem onSelect={() => go("/dashboard")}>
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/inbox")}>
|
||||
<Inbox className="mr-2 h-4 w-4" />
|
||||
Inbox
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/issues")}>
|
||||
<CircleDot className="mr-2 h-4 w-4" />
|
||||
Issues
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/projects")}>
|
||||
<Hexagon className="mr-2 h-4 w-4" />
|
||||
Projects
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/goals")}>
|
||||
<Target className="mr-2 h-4 w-4" />
|
||||
Goals
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/agents")}>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
Agents
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/costs")}>
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
Costs
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/activity")}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Activity
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
{visibleIssues.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Issues">
|
||||
{visibleIssues.slice(0, 10).map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
value={
|
||||
searchQuery.length > 0
|
||||
? `${searchQuery} ${issue.identifier ?? ""} ${issue.title}`
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => go(`/issues/${issue.identifier ?? issue.id}`)}
|
||||
>
|
||||
<CircleDot className="mr-2 h-4 w-4" />
|
||||
<span className="text-muted-foreground mr-2 font-mono text-xs">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{issue.title}</span>
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name ? <Identity name={name} size="sm" className="ml-2 hidden sm:inline-flex" /> : null;
|
||||
})()}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{agents.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Agents">
|
||||
{agents.slice(0, 10).map((agent) => (
|
||||
<CommandItem key={agent.id} onSelect={() => go(agentUrl(agent))}>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
{agent.name}
|
||||
<span className="text-xs text-muted-foreground ml-2">{agent.role}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Projects">
|
||||
{projects.slice(0, 10).map((project) => (
|
||||
<CommandItem key={project.id} onSelect={() => go(projectUrl(project))}>
|
||||
<Hexagon className="mr-2 h-4 w-4" />
|
||||
{project.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import type { IssueComment, Agent } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, Copy, Paperclip } from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { formatDateTime } from "../lib/utils";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
|
||||
interface CommentWithRunMeta extends IssueComment {
|
||||
runId?: string | null;
|
||||
runAgentId?: string | null;
|
||||
}
|
||||
|
||||
interface LinkedRunItem {
|
||||
runId: string;
|
||||
status: string;
|
||||
agentId: string;
|
||||
createdAt: Date | string;
|
||||
startedAt: Date | string | null;
|
||||
}
|
||||
|
||||
interface CommentReassignment {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
|
||||
interface CommentThreadProps {
|
||||
comments: CommentWithRunMeta[];
|
||||
linkedRuns?: LinkedRunItem[];
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||
issueStatus?: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
/** Callback to attach an image file to the parent issue (not inline in a comment). */
|
||||
onAttachImage?: (file: File) => Promise<void>;
|
||||
draftKey?: string;
|
||||
liveRunSlot?: React.ReactNode;
|
||||
enableReassign?: boolean;
|
||||
reassignOptions?: InlineEntityOption[];
|
||||
currentAssigneeValue?: string;
|
||||
suggestedAssigneeValue?: string;
|
||||
mentions?: MentionOption[];
|
||||
}
|
||||
|
||||
const DRAFT_DEBOUNCE_MS = 800;
|
||||
|
||||
function loadDraft(draftKey: string): string {
|
||||
try {
|
||||
return localStorage.getItem(draftKey) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(draftKey: string, value: string) {
|
||||
try {
|
||||
if (value.trim()) {
|
||||
localStorage.setItem(draftKey, value);
|
||||
} else {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(draftKey: string) {
|
||||
try {
|
||||
localStorage.removeItem(draftKey);
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
function parseReassignment(target: string): CommentReassignment | null {
|
||||
if (!target || target === "__none__") {
|
||||
return { assigneeAgentId: null, assigneeUserId: null };
|
||||
}
|
||||
if (target.startsWith("agent:")) {
|
||||
const assigneeAgentId = target.slice("agent:".length);
|
||||
return assigneeAgentId ? { assigneeAgentId, assigneeUserId: null } : null;
|
||||
}
|
||||
if (target.startsWith("user:")) {
|
||||
const assigneeUserId = target.slice("user:".length);
|
||||
return assigneeUserId ? { assigneeAgentId: null, assigneeUserId } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function CopyMarkdownButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy as markdown"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineItem =
|
||||
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
||||
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
||||
|
||||
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) {
|
||||
return <p className="text-sm text-muted-foreground">No comments or runs yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "run") {
|
||||
const run = item.run;
|
||||
return (
|
||||
<div key={`run:${run.runId}`} className="border border-border bg-accent/20 p-3 overflow-hidden min-w-0 rounded-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Link to={`/agents/${run.agentId}`} className="hover:underline">
|
||||
<Identity
|
||||
name={agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8)}
|
||||
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.runId}`}
|
||||
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.runId.slice(0, 8)}
|
||||
</Link>
|
||||
<StatusBadge status={run.status} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const comment = item.comment;
|
||||
const isHighlighted = highlightCommentId === comment.id;
|
||||
return (
|
||||
<div
|
||||
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">
|
||||
{companyId ? (
|
||||
<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}
|
||||
<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 ? (
|
||||
<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 && (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function CommentThread({
|
||||
comments,
|
||||
linkedRuns = [],
|
||||
companyId,
|
||||
projectId,
|
||||
onAdd,
|
||||
agentMap,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
liveRunSlot,
|
||||
enableReassign = false,
|
||||
reassignOptions = [],
|
||||
currentAssigneeValue = "",
|
||||
suggestedAssigneeValue,
|
||||
mentions: providedMentions,
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const 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);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const location = useLocation();
|
||||
const hasScrolledRef = useRef(false);
|
||||
|
||||
const timeline = useMemo<TimelineItem[]>(() => {
|
||||
const commentItems: TimelineItem[] = comments.map((comment) => ({
|
||||
kind: "comment",
|
||||
id: comment.id,
|
||||
createdAtMs: new Date(comment.createdAt).getTime(),
|
||||
comment,
|
||||
}));
|
||||
const runItems: TimelineItem[] = linkedRuns.map((run) => ({
|
||||
kind: "run",
|
||||
id: run.runId,
|
||||
createdAtMs: new Date(run.startedAt ?? run.createdAt).getTime(),
|
||||
run,
|
||||
}));
|
||||
return [...commentItems, ...runItems].sort((a, b) => {
|
||||
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
|
||||
if (a.kind === b.kind) return a.id.localeCompare(b.id);
|
||||
return a.kind === "comment" ? -1 : 1;
|
||||
});
|
||||
}, [comments, linkedRuns]);
|
||||
|
||||
// Build mention options from agent map (exclude terminated agents)
|
||||
const mentions = useMemo<MentionOption[]>(() => {
|
||||
if (providedMentions) return providedMentions;
|
||||
if (!agentMap) return [];
|
||||
return Array.from(agentMap.values())
|
||||
.filter((a) => a.status !== "terminated")
|
||||
.map((a) => ({
|
||||
id: `agent:${a.id}`,
|
||||
name: a.name,
|
||||
kind: "agent",
|
||||
agentId: a.id,
|
||||
agentIcon: a.icon,
|
||||
}));
|
||||
}, [agentMap, providedMentions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
setBody(loadDraft(draftKey));
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(() => {
|
||||
saveDraft(draftKey, body);
|
||||
}, DRAFT_DEBOUNCE_MS);
|
||||
}, [body, draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
const commentId = hash.slice("#comment-".length);
|
||||
// Only scroll once per hash
|
||||
if (hasScrolledRef.current) return;
|
||||
const el = document.getElementById(`comment-${commentId}`);
|
||||
if (el) {
|
||||
hasScrolledRef.current = true;
|
||||
setHighlightCommentId(commentId);
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Clear highlight after animation
|
||||
const timer = setTimeout(() => setHighlightCommentId(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [location.hash, comments]);
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
|
||||
setBody("");
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(true);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAttaching(true);
|
||||
try {
|
||||
if (imageUploadHandler) {
|
||||
const url = await imageUploadHandler(file);
|
||||
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
||||
const markdown = ``;
|
||||
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
|
||||
} else if (onAttachImage) {
|
||||
await onAttachImage(file);
|
||||
}
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
if (attachInputRef.current) attachInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = !submitting && !!body.trim();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Comments & Runs ({timeline.length})</h3>
|
||||
|
||||
<TimelineList
|
||||
timeline={timeline}
|
||||
agentMap={agentMap}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
/>
|
||||
|
||||
{liveRunSlot}
|
||||
|
||||
<div className="space-y-2">
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Leave a comment..."
|
||||
mentions={mentions}
|
||||
onSubmit={handleSubmit}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
contentClassName="min-h-[60px] text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{(imageUploadHandler || onAttachImage) && (
|
||||
<div className="mr-auto flex items-center gap-3">
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleAttachFile}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={attaching}
|
||||
title="Attach image"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<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}
|
||||
options={reassignOptions}
|
||||
placeholder="Assignee"
|
||||
noneLabel="No assignee"
|
||||
searchPlaceholder="Search assignees..."
|
||||
emptyMessage="No assignees found."
|
||||
onChange={setReassignTarget}
|
||||
className="text-xs h-8"
|
||||
renderTriggerValue={(option) => {
|
||||
if (!option) return <span className="text-muted-foreground">Assignee</span>;
|
||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{submitting ? "Posting..." : "Comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const BAYER_4X4 = [
|
||||
[0, 8, 2, 10],
|
||||
[12, 4, 14, 6],
|
||||
[3, 11, 1, 9],
|
||||
[15, 7, 13, 5],
|
||||
] as const;
|
||||
|
||||
interface CompanyPatternIconProps {
|
||||
companyName: string;
|
||||
logoUrl?: string | null;
|
||||
brandColor?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function hashString(value: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
const hue = ((h % 360) + 360) % 360;
|
||||
const sat = Math.max(0, Math.min(100, s)) / 100;
|
||||
const light = Math.max(0, Math.min(100, l)) / 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * light - 1)) * sat;
|
||||
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const m = light - c / 2;
|
||||
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
if (hue < 60) {
|
||||
r = c;
|
||||
g = x;
|
||||
} else if (hue < 120) {
|
||||
r = x;
|
||||
g = c;
|
||||
} else if (hue < 180) {
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (hue < 240) {
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (hue < 300) {
|
||||
r = x;
|
||||
b = c;
|
||||
} else {
|
||||
r = c;
|
||||
b = x;
|
||||
}
|
||||
|
||||
return [
|
||||
Math.round((r + m) * 255),
|
||||
Math.round((g + m) * 255),
|
||||
Math.round((b + m) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
function hexToHue(hex: string): number {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
if (d === 0) return 0;
|
||||
let h = 0;
|
||||
if (max === r) h = ((g - b) / d) % 6;
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
return ((h * 60) + 360) % 360;
|
||||
}
|
||||
|
||||
function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, logicalSize = 22, cellSize = 2): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = logicalSize * cellSize;
|
||||
canvas.height = logicalSize * cellSize;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return "";
|
||||
|
||||
const rand = mulberry32(hashString(seed));
|
||||
|
||||
const hue = brandColor ? hexToHue(brandColor) : Math.floor(rand() * 360);
|
||||
const [offR, offG, offB] = hslToRgb(
|
||||
hue,
|
||||
54 + Math.floor(rand() * 14),
|
||||
36 + Math.floor(rand() * 12),
|
||||
);
|
||||
const [onR, onG, onB] = hslToRgb(
|
||||
hue + (rand() > 0.5 ? 10 : -10),
|
||||
86 + Math.floor(rand() * 10),
|
||||
82 + Math.floor(rand() * 10),
|
||||
);
|
||||
|
||||
const center = (logicalSize - 1) / 2;
|
||||
const half = Math.max(center, 1);
|
||||
const gradientAngle = rand() * Math.PI * 2;
|
||||
const gradientDirX = Math.cos(gradientAngle);
|
||||
const gradientDirY = Math.sin(gradientAngle);
|
||||
const maxProjection = Math.abs(gradientDirX * half) + Math.abs(gradientDirY * half);
|
||||
const diagonalFrequency = 0.34 + rand() * 0.12;
|
||||
const antiDiagonalFrequency = 0.33 + rand() * 0.12;
|
||||
const diagonalPhase = rand() * Math.PI * 2;
|
||||
const antiDiagonalPhase = rand() * Math.PI * 2;
|
||||
|
||||
ctx.fillStyle = `rgb(${offR} ${offG} ${offB})`;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.fillStyle = `rgb(${onR} ${onG} ${onB})`;
|
||||
const dotRadius = cellSize * 0.46;
|
||||
|
||||
for (let y = 0; y < logicalSize; y++) {
|
||||
const dy = y - center;
|
||||
|
||||
for (let x = 0; x < logicalSize; x++) {
|
||||
const dx = x - center;
|
||||
|
||||
// Side-to-side signal where visible gradient is produced by dither density.
|
||||
const projection = dx * gradientDirX + dy * gradientDirY;
|
||||
const gradient = (projection / maxProjection + 1) * 0.5;
|
||||
const diagonal = Math.sin((dx + dy) * diagonalFrequency + diagonalPhase) * 0.5 + 0.5;
|
||||
const antiDiagonal = Math.sin((dx - dy) * antiDiagonalFrequency + antiDiagonalPhase) * 0.5 + 0.5;
|
||||
const hatch = diagonal * 0.5 + antiDiagonal * 0.5;
|
||||
const signal = Math.max(0, Math.min(1, gradient + (hatch - 0.5) * 0.22));
|
||||
|
||||
// Canonical 16-level ordered dither: level 0..15 compared to Bayer 4x4 threshold index.
|
||||
const level = Math.max(0, Math.min(15, Math.floor(signal * 16)));
|
||||
const thresholdIndex = BAYER_4X4[y & 3]![x & 3]!;
|
||||
if (level <= thresholdIndex) continue;
|
||||
|
||||
const cx = x * cellSize + cellSize / 2;
|
||||
const cy = y * cellSize + cellSize / 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, dotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-11 h-11 text-base font-semibold text-white overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{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=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
)}
|
||||
{!logo && (
|
||||
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Paperclip, Plus } from "lucide-react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
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,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
||||
|
||||
const ORDER_STORAGE_KEY = "paperclip.companyOrder";
|
||||
|
||||
function getStoredOrder(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(ORDER_STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveOrder(ids: string[]) {
|
||||
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids));
|
||||
}
|
||||
|
||||
/** Sort companies by stored order, appending any new ones at the end. */
|
||||
function sortByStoredOrder(companies: Company[]): Company[] {
|
||||
const order = getStoredOrder();
|
||||
if (order.length === 0) return companies;
|
||||
|
||||
const byId = new Map(companies.map((c) => [c.id, c]));
|
||||
const sorted: Company[] = [];
|
||||
|
||||
for (const id of order) {
|
||||
const c = byId.get(id);
|
||||
if (c) {
|
||||
sorted.push(c);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
// Append any companies not in stored order
|
||||
for (const c of byId.values()) {
|
||||
sorted.push(c);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isSelected,
|
||||
hasLiveAgents,
|
||||
hasUnreadInbox,
|
||||
onSelect,
|
||||
}: {
|
||||
company: Company;
|
||||
isSelected: boolean;
|
||||
hasLiveAgents: boolean;
|
||||
hasUnreadInbox: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: company.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="overflow-visible">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={`/${company.issuePrefix}/dashboard`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
className="relative flex items-center justify-center group overflow-visible"
|
||||
>
|
||||
{/* Selection indicator pill */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-[height] duration-150",
|
||||
isSelected
|
||||
? "h-5"
|
||||
: "h-0 group-hover:h-2"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn("relative overflow-visible transition-transform duration-150", isDragging && "scale-105")}
|
||||
>
|
||||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
logoUrl={company.logoUrl}
|
||||
brandColor={company.brandColor}
|
||||
className={cn(
|
||||
isSelected
|
||||
? "rounded-[14px]"
|
||||
: "rounded-[22px] group-hover:rounded-[14px]",
|
||||
isDragging && "shadow-lg",
|
||||
)}
|
||||
/>
|
||||
{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-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>
|
||||
)}
|
||||
{hasUnreadInbox && (
|
||||
<span className="pointer-events-none absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-background" />
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>{company.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
|
||||
|
||||
const liveRunsQueries = useQueries({
|
||||
queries: companyIds.map((companyId) => ({
|
||||
queryKey: queryKeys.liveRuns(companyId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||||
refetchInterval: 10_000,
|
||||
})),
|
||||
});
|
||||
const sidebarBadgeQueries = useQueries({
|
||||
queries: companyIds.map((companyId) => ({
|
||||
queryKey: queryKeys.sidebarBadges(companyId),
|
||||
queryFn: () => sidebarBadgesApi.get(companyId),
|
||||
refetchInterval: 15_000,
|
||||
})),
|
||||
});
|
||||
const hasLiveAgentsByCompanyId = useMemo(() => {
|
||||
const result = new Map<string, boolean>();
|
||||
companyIds.forEach((companyId, index) => {
|
||||
result.set(companyId, (liveRunsQueries[index]?.data?.length ?? 0) > 0);
|
||||
});
|
||||
return result;
|
||||
}, [companyIds, liveRunsQueries]);
|
||||
const hasUnreadInboxByCompanyId = useMemo(() => {
|
||||
const result = new Map<string, boolean>();
|
||||
companyIds.forEach((companyId, index) => {
|
||||
result.set(companyId, (sidebarBadgeQueries[index]?.data?.inbox ?? 0) > 0);
|
||||
});
|
||||
return result;
|
||||
}, [companyIds, sidebarBadgeQueries]);
|
||||
|
||||
// Maintain sorted order in local state, synced from companies + localStorage
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
|
||||
sortByStoredOrder(sidebarCompanies).map((c) => c.id)
|
||||
);
|
||||
|
||||
// Re-sync orderedIds from localStorage whenever companies changes.
|
||||
// Handles initial data load (companies starts as [] before query resolves)
|
||||
// and subsequent refetches triggered by live updates.
|
||||
useEffect(() => {
|
||||
if (sidebarCompanies.length === 0) {
|
||||
setOrderedIds([]);
|
||||
return;
|
||||
}
|
||||
setOrderedIds(sortByStoredOrder(sidebarCompanies).map((c) => c.id));
|
||||
}, [sidebarCompanies]);
|
||||
|
||||
// Sync order across tabs via the native storage event
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key !== ORDER_STORAGE_KEY) return;
|
||||
try {
|
||||
const ids: string[] = e.newValue ? JSON.parse(e.newValue) : [];
|
||||
setOrderedIds(ids);
|
||||
} catch { /* ignore malformed data */ }
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
// Re-derive when companies change (new company added/removed)
|
||||
const orderedCompanies = useMemo(() => {
|
||||
const byId = new Map(sidebarCompanies.map((c) => [c.id, c]));
|
||||
const result: Company[] = [];
|
||||
for (const id of orderedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c) {
|
||||
result.push(c);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
// Append any new companies not yet in our order
|
||||
for (const c of byId.values()) {
|
||||
result.push(c);
|
||||
}
|
||||
return result;
|
||||
}, [sidebarCompanies, orderedIds]);
|
||||
|
||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const ids = orderedCompanies.map((c) => c.id);
|
||||
const oldIndex = ids.indexOf(active.id as string);
|
||||
const newIndex = ids.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const newIds = arrayMove(ids, oldIndex, newIndex);
|
||||
setOrderedIds(newIds);
|
||||
saveOrder(newIds);
|
||||
},
|
||||
[orderedCompanies]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-[72px] shrink-0 h-full bg-background border-r border-border">
|
||||
{/* Paperclip icon - aligned with top sections (implied line, no visible border) */}
|
||||
<div className="flex items-center justify-center h-12 w-full shrink-0">
|
||||
<Paperclip className="h-5 w-5 text-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Company list */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2 py-3 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedCompanies.map((c) => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{orderedCompanies.map((company) => (
|
||||
<SortableCompanyItem
|
||||
key={company.id}
|
||||
company={company}
|
||||
isSelected={company.id === highlightedCompanyId}
|
||||
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
|
||||
hasUnreadInbox={hasUnreadInboxByCompanyId.get(company.id) ?? false}
|
||||
onSelect={() => {
|
||||
setSelectedCompanyId(company.id);
|
||||
if (isInstanceRoute) {
|
||||
navigate(`/${company.issuePrefix}/dashboard`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Separator before add button */}
|
||||
<div className="w-8 h-px bg-border mx-auto shrink-0" />
|
||||
|
||||
{/* Add company button */}
|
||||
<div className="flex items-center justify-center py-2 shrink-0">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => openOnboarding()}
|
||||
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-[14px] border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-[border-color,color,border-radius] duration-150"
|
||||
aria-label="Add company"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>Add company</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ChevronsUpDown, Plus, Settings } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function statusDotColor(status?: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-green-400";
|
||||
case "paused":
|
||||
return "bg-yellow-400";
|
||||
case "archived":
|
||||
return "bg-neutral-400";
|
||||
default:
|
||||
return "bg-green-400";
|
||||
}
|
||||
}
|
||||
|
||||
export function CompanySwitcher() {
|
||||
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
||||
const sidebarCompanies = companies.filter((company) => company.status !== "archived");
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-2 py-1.5 h-auto text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{selectedCompany && (
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${statusDotColor(selectedCompany.status)}`} />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
{selectedCompany?.name ?? "Select company"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[220px]">
|
||||
<DropdownMenuLabel>Companies</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{sidebarCompanies.map((company) => (
|
||||
<DropdownMenuItem
|
||||
key={company.id}
|
||||
onClick={() => setSelectedCompanyId(company.id)}
|
||||
className={company.id === selectedCompany?.id ? "bg-accent" : ""}
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 mr-2 ${statusDotColor(company.status)}`} />
|
||||
<span className="truncate">{company.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{sidebarCompanies.length === 0 && (
|
||||
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/company/settings" className="no-underline text-inherit">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Company Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/companies" className="no-underline text-inherit">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Manage Companies
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CopyTextProps {
|
||||
text: string;
|
||||
/** What to display. Defaults to `text`. */
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
/** Tooltip message shown after copying. Default: "Copied!" */
|
||||
copiedLabel?: string;
|
||||
}
|
||||
|
||||
export function CopyText({ text, children, className, copiedLabel = "Copied!" }: CopyTextProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [label, setLabel] = useState(copiedLabel);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setLabel(copiedLabel);
|
||||
} catch {
|
||||
setLabel("Copy failed");
|
||||
}
|
||||
clearTimeout(timerRef.current);
|
||||
setVisible(true);
|
||||
timerRef.current = setTimeout(() => setVisible(false), 1500);
|
||||
}, [copiedLabel, text]);
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-copy hover:text-foreground transition-colors",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children ?? text}
|
||||
</button>
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-full mb-1.5 rounded-md bg-foreground text-background px-2 py-1 text-xs whitespace-nowrap transition-opacity duration-300",
|
||||
visible ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
message: string;
|
||||
action?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, message, action, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="bg-muted/50 p-4 mb-4">
|
||||
<Icon className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">{message}</p>
|
||||
{action && onAction && (
|
||||
<Button onClick={onAction}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
{action}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface EntityRowProps {
|
||||
leading?: ReactNode;
|
||||
identifier?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
trailing?: ReactNode;
|
||||
selected?: boolean;
|
||||
to?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EntityRow({
|
||||
leading,
|
||||
identifier,
|
||||
title,
|
||||
subtitle,
|
||||
trailing,
|
||||
selected,
|
||||
to,
|
||||
onClick,
|
||||
className,
|
||||
}: EntityRowProps) {
|
||||
const isClickable = !!(to || onClick);
|
||||
const classes = cn(
|
||||
"flex items-center gap-3 px-4 py-2 text-sm border-b border-border last:border-b-0 transition-colors",
|
||||
isClickable && "cursor-pointer hover:bg-accent/50",
|
||||
selected && "bg-accent/30",
|
||||
className
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{identifier && (
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0 relative top-[1px]">
|
||||
{identifier}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link to={to} className={cn(classes, "no-underline text-inherit")} onClick={onClick}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes} onClick={onClick}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export interface FilterValue {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: FilterValue[];
|
||||
onRemove: (key: string) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function FilterBar({ filters, onRemove, onClear }: FilterBarProps) {
|
||||
if (filters.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{filters.map((f) => (
|
||||
<Badge key={f.key} variant="secondary" className="gap-1 pr-1">
|
||||
<span className="text-muted-foreground">{f.label}:</span>
|
||||
<span>{f.value}</span>
|
||||
<button
|
||||
className="ml-1 rounded-full hover:bg-accent p-0.5"
|
||||
onClick={() => onRemove(f.key)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="text-xs h-6" onClick={onClear}>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Goal } from "@paperclipai/shared";
|
||||
import { GOAL_STATUSES, GOAL_LEVELS } from "@paperclipai/shared";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { formatDate, cn, agentUrl } from "../lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface GoalPropertiesProps {
|
||||
goal: Goal;
|
||||
onUpdate?: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function label(s: string): string {
|
||||
return s.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function PickerButton({
|
||||
current,
|
||||
options,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
current: string;
|
||||
options: readonly string[];
|
||||
onChange: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="cursor-pointer hover:opacity-80 transition-opacity">
|
||||
{children}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="end">
|
||||
{options.map((opt) => (
|
||||
<Button
|
||||
key={opt}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("w-full justify-start text-xs", opt === current && "bg-accent")}
|
||||
onClick={() => {
|
||||
onChange(opt);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{label(opt)}
|
||||
</Button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoalProperties({ goal, onUpdate }: GoalPropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: allGoals } = useQuery({
|
||||
queryKey: queryKeys.goals.list(selectedCompanyId!),
|
||||
queryFn: () => goalsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const ownerAgent = goal.ownerAgentId
|
||||
? agents?.find((a) => a.id === goal.ownerAgentId)
|
||||
: null;
|
||||
|
||||
const parentGoal = goal.parentId
|
||||
? allGoals?.find((g) => g.id === goal.parentId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Status">
|
||||
{onUpdate ? (
|
||||
<PickerButton
|
||||
current={goal.status}
|
||||
options={GOAL_STATUSES}
|
||||
onChange={(status) => onUpdate({ status })}
|
||||
>
|
||||
<StatusBadge status={goal.status} />
|
||||
</PickerButton>
|
||||
) : (
|
||||
<StatusBadge status={goal.status} />
|
||||
)}
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Level">
|
||||
{onUpdate ? (
|
||||
<PickerButton
|
||||
current={goal.level}
|
||||
options={GOAL_LEVELS}
|
||||
onChange={(level) => onUpdate({ level })}
|
||||
>
|
||||
<span className="text-sm capitalize">{goal.level}</span>
|
||||
</PickerButton>
|
||||
) : (
|
||||
<span className="text-sm capitalize">{goal.level}</span>
|
||||
)}
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Owner">
|
||||
{ownerAgent ? (
|
||||
<Link
|
||||
to={agentUrl(ownerAgent)}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{ownerAgent.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">None</span>
|
||||
)}
|
||||
</PropertyRow>
|
||||
|
||||
{goal.parentId && (
|
||||
<PropertyRow label="Parent Goal">
|
||||
<Link
|
||||
to={`/goals/${goal.parentId}`}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{parentGoal?.title ?? goal.parentId.slice(0, 8)}
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Created">
|
||||
<span className="text-sm">{formatDate(goal.createdAt)}</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Updated">
|
||||
<span className="text-sm">{formatDate(goal.updatedAt)}</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Goal } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
interface GoalTreeProps {
|
||||
goals: Goal[];
|
||||
goalLink?: (goal: Goal) => string;
|
||||
onSelect?: (goal: Goal) => void;
|
||||
}
|
||||
|
||||
interface GoalNodeProps {
|
||||
goal: Goal;
|
||||
children: Goal[];
|
||||
allGoals: Goal[];
|
||||
depth: number;
|
||||
goalLink?: (goal: Goal) => string;
|
||||
onSelect?: (goal: Goal) => void;
|
||||
}
|
||||
|
||||
function GoalNode({ goal, children, allGoals, depth, goalLink, onSelect }: GoalNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const hasChildren = children.length > 0;
|
||||
const link = goalLink?.(goal);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="p-0.5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground capitalize">{goal.level}</span>
|
||||
<span className="flex-1 truncate">{goal.title}</span>
|
||||
<StatusBadge status={goal.status} />
|
||||
</>
|
||||
);
|
||||
|
||||
const classes = cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-accent/50",
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{link ? (
|
||||
<Link
|
||||
to={link}
|
||||
className={cn(classes, "no-underline text-inherit")}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={classes}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
onClick={() => onSelect?.(goal)}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
)}
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<GoalNode
|
||||
key={child.id}
|
||||
goal={child}
|
||||
children={allGoals.filter((g) => g.parentId === child.id)}
|
||||
allGoals={allGoals}
|
||||
depth={depth + 1}
|
||||
goalLink={goalLink}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoalTree({ goals, goalLink, onSelect }: GoalTreeProps) {
|
||||
const goalIds = new Set(goals.map((g) => g.id));
|
||||
const roots = goals.filter((g) => !g.parentId || !goalIds.has(g.parentId));
|
||||
|
||||
if (goals.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No goals.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border py-1">
|
||||
{roots.map((goal) => (
|
||||
<GoalNode
|
||||
key={goal.id}
|
||||
goal={goal}
|
||||
children={goals.filter((g) => g.parentId === goal.id)}
|
||||
allGoals={goals}
|
||||
depth={0}
|
||||
goalLink={goalLink}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
type IdentitySize = "xs" | "sm" | "default" | "lg";
|
||||
|
||||
export interface IdentityProps {
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
initials?: string;
|
||||
size?: IdentitySize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function deriveInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
const textSize: Record<IdentitySize, string> = {
|
||||
xs: "text-sm",
|
||||
sm: "text-xs",
|
||||
default: "text-sm",
|
||||
lg: "text-sm",
|
||||
};
|
||||
|
||||
export function Identity({ name, avatarUrl, initials, size = "default", className }: IdentityProps) {
|
||||
const displayInitials = initials ?? deriveInitials(name);
|
||||
|
||||
return (
|
||||
<span className={cn("inline-flex gap-1.5", size === "xs" ? "items-baseline gap-1" : "items-center", size === "lg" && "gap-2", className)}>
|
||||
<Avatar size={size} className={size === "xs" ? "relative -top-px" : undefined}>
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
|
||||
<AvatarFallback>{displayInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className={cn("truncate", textSize[size])}>{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
|
||||
|
||||
interface InlineEditorProps {
|
||||
value: string;
|
||||
onSave: (value: string) => void | Promise<unknown>;
|
||||
as?: "h1" | "h2" | "p" | "span";
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
mentions?: MentionOption[];
|
||||
}
|
||||
|
||||
/** 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,
|
||||
onSave,
|
||||
as: Tag = "span",
|
||||
className,
|
||||
placeholder = "Click to edit...",
|
||||
multiline = false,
|
||||
imageUploadHandler,
|
||||
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, multiline, multilineFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const autoSize = useCallback((el: HTMLTextAreaElement | null) => {
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
if (inputRef.current instanceof HTMLTextAreaElement) {
|
||||
autoSize(inputRef.current);
|
||||
}
|
||||
}
|
||||
}, [editing, autoSize]);
|
||||
|
||||
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) {
|
||||
await Promise.resolve(onSave(trimmed));
|
||||
} else {
|
||||
setDraft(value);
|
||||
}
|
||||
if (!multiline) {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [draft, multiline, onSave, value]);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && !multiline) {
|
||||
e.preventDefault();
|
||||
void commit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
reset();
|
||||
setDraft(value);
|
||||
if (multiline) {
|
||||
setMultilineFocused(false);
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
} else {
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
rows={1}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
autoSize(e.target);
|
||||
}}
|
||||
onBlur={() => {
|
||||
void commit();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-full bg-transparent rounded outline-none resize-none overflow-hidden",
|
||||
pad,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Use div instead of Tag when rendering markdown to avoid invalid nesting
|
||||
// (e.g. <p> cannot contain the <div>/<p> elements that markdown produces)
|
||||
const DisplayTag = value && multiline ? "div" : Tag;
|
||||
|
||||
return (
|
||||
<DisplayTag
|
||||
className={cn(
|
||||
"cursor-pointer rounded hover:bg-accent/50 transition-colors overflow-hidden",
|
||||
pad,
|
||||
!value && "text-muted-foreground italic",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value || placeholder}
|
||||
</DisplayTag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface InlineEntityOption {
|
||||
id: string;
|
||||
label: string;
|
||||
searchText?: string;
|
||||
}
|
||||
|
||||
interface InlineEntitySelectorProps {
|
||||
value: string;
|
||||
options: InlineEntityOption[];
|
||||
placeholder: string;
|
||||
noneLabel: string;
|
||||
searchPlaceholder: string;
|
||||
emptyMessage: string;
|
||||
onChange: (id: string) => void;
|
||||
onConfirm?: () => void;
|
||||
className?: string;
|
||||
renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode;
|
||||
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
|
||||
/** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */
|
||||
disablePortal?: boolean;
|
||||
}
|
||||
|
||||
export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>(
|
||||
function InlineEntitySelector(
|
||||
{
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
noneLabel,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
onChange,
|
||||
onConfirm,
|
||||
className,
|
||||
renderTriggerValue,
|
||||
renderOption,
|
||||
disablePortal,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const shouldPreventCloseAutoFocusRef = useRef(false);
|
||||
const isPointerDownRef = useRef(false);
|
||||
|
||||
const allOptions = useMemo<InlineEntityOption[]>(
|
||||
() => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options],
|
||||
[noneLabel, options],
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const term = query.trim().toLowerCase();
|
||||
if (!term) return allOptions;
|
||||
return allOptions.filter((option) => {
|
||||
const haystack = `${option.label} ${option.searchText ?? ""}`.toLowerCase();
|
||||
return haystack.includes(term);
|
||||
});
|
||||
}, [allOptions, query]);
|
||||
|
||||
const currentOption = options.find((option) => option.id === value) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const selectedIndex = filteredOptions.findIndex((option) => option.id === value);
|
||||
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
}, [filteredOptions, open, value]);
|
||||
|
||||
const commitSelection = (index: number, moveNext: boolean) => {
|
||||
const option = filteredOptions[index] ?? filteredOptions[0];
|
||||
if (option) onChange(option.id);
|
||||
shouldPreventCloseAutoFocusRef.current = moveNext;
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
if (moveNext && onConfirm) {
|
||||
requestAnimationFrame(() => {
|
||||
onConfirm();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setQuery("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-1 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
onPointerDown={() => { isPointerDownRef.current = true; }}
|
||||
onFocus={() => {
|
||||
if (!isPointerDownRef.current) setOpen(true);
|
||||
isPointerDownRef.current = false;
|
||||
}}
|
||||
>
|
||||
{renderTriggerValue
|
||||
? renderTriggerValue(currentOption)
|
||||
: (currentOption?.label ?? <span className="text-muted-foreground">{placeholder}</span>)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
collisionPadding={16}
|
||||
className="w-[min(20rem,calc(100vw-2rem))] p-1"
|
||||
disablePortal={disablePortal}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
// On touch devices, don't auto-focus the search input to avoid
|
||||
// opening the virtual keyboard which reshapes the viewport and
|
||||
// pushes the popover off-screen.
|
||||
const isTouch = window.matchMedia("(pointer: coarse)").matches;
|
||||
if (!isTouch) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!shouldPreventCloseAutoFocusRef.current) return;
|
||||
event.preventDefault();
|
||||
shouldPreventCloseAutoFocusRef.current = false;
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground/60"
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((current) =>
|
||||
filteredOptions.length === 0 ? 0 : (current + 1) % filteredOptions.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex((current) => {
|
||||
if (filteredOptions.length === 0) return 0;
|
||||
return current <= 0 ? filteredOptions.length - 1 : current - 1;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitSelection(highlightedIndex, true);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
commitSelection(highlightedIndex, true);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="max-h-56 overflow-y-auto overscroll-contain py-1 touch-pan-y">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p className="px-2 py-2 text-xs text-muted-foreground">{emptyMessage}</p>
|
||||
) : (
|
||||
filteredOptions.map((option, index) => {
|
||||
const isSelected = option.id === value;
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={option.id || "__none__"}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm touch-manipulation",
|
||||
isHighlighted && "bg-accent",
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onClick={() => commitSelection(index, true)}
|
||||
>
|
||||
{renderOption ? renderOption(option, isSelected) : <span className="truncate">{option.label}</span>}
|
||||
<Check className={cn("ml-auto h-3.5 w-3.5 text-muted-foreground", isSelected ? "opacity-100" : "opacity-0")} />
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
import { useCallback, useMemo, useRef, 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";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
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";
|
||||
import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, Copy, Check } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
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 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";
|
||||
}
|
||||
|
||||
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"),
|
||||
);
|
||||
}
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
issue: Issue;
|
||||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */
|
||||
function PropertyPicker({
|
||||
inline,
|
||||
label,
|
||||
open,
|
||||
onOpenChange,
|
||||
triggerContent,
|
||||
triggerClassName,
|
||||
popoverClassName,
|
||||
popoverAlign = "end",
|
||||
extra,
|
||||
children,
|
||||
}: {
|
||||
inline?: boolean;
|
||||
label: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
triggerContent: React.ReactNode;
|
||||
triggerClassName?: string;
|
||||
popoverClassName?: string;
|
||||
popoverAlign?: "start" | "center" | "end";
|
||||
extra?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const btnCn = cn(
|
||||
"inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors",
|
||||
triggerClassName,
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div>
|
||||
<PropertyRow label={label}>
|
||||
<button className={btnCn} onClick={() => onOpenChange(!open)}>
|
||||
{triggerContent}
|
||||
</button>
|
||||
{extra}
|
||||
</PropertyRow>
|
||||
{open && (
|
||||
<div className={cn("rounded-md border border-border bg-popover p-1 mb-2", popoverClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyRow label={label}>
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={btnCn}>{triggerContent}</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn("p-1", popoverClassName)} align={popoverAlign} collisionPadding={16}>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{extra}
|
||||
</PropertyRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** Splits a string at `/` and `-` boundaries, inserting <wbr> for natural line breaks. */
|
||||
function BreakablePath({ text }: { text: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
// Split on path separators and hyphens, keeping them in the output
|
||||
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}</>;
|
||||
}
|
||||
|
||||
/** Displays a value with a copy-to-clipboard icon and "Copied!" feedback. */
|
||||
function CopyableValue({ value, label, mono, className }: { value: string; label?: string; mono?: boolean; className?: string }) {
|
||||
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 (
|
||||
<div className={cn("flex items-start gap-1 group", className)}>
|
||||
<span className="min-w-0" style={{ overflowWrap: "anywhere" }}>
|
||||
{label && <span className="text-muted-foreground">{label} </span>}
|
||||
<span className={mono ? "font-mono" : undefined}><BreakablePath text={value} /></span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 mt-0.5 p-0.5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
onClick={handleCopy}
|
||||
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const companyId = issue.companyId ?? selectedCompanyId;
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
const [projectOpen, setProjectOpen] = useState(false);
|
||||
const [projectSearch, setProjectSearch] = useState("");
|
||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||
const [labelSearch, setLabelSearch] = useState("");
|
||||
const [newLabelName, setNewLabelName] = useState("");
|
||||
const [newLabelColor, setNewLabelColor] = useState("#6366f1");
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId;
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId!),
|
||||
queryFn: () => agentsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId!),
|
||||
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: activeProjects,
|
||||
companyId,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(companyId!),
|
||||
queryFn: () => issuesApi.listLabels(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const createLabel = useMutation({
|
||||
mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data),
|
||||
onSuccess: async (created) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
|
||||
onUpdate({ labelIds: [...(issue.labelIds ?? []), created.id] });
|
||||
setNewLabelName("");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteLabel = useMutation({
|
||||
mutationFn: (labelId: string) => issuesApi.deleteLabel(labelId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleLabel = (labelId: string) => {
|
||||
const ids = issue.labelIds ?? [];
|
||||
const next = ids.includes(labelId)
|
||||
? ids.filter((id) => id !== labelId)
|
||||
: [...ids, labelId];
|
||||
onUpdate({ labelIds: next });
|
||||
};
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id || !agents) return null;
|
||||
const agent = agents.find((a) => a.id === id);
|
||||
return agent?.name ?? id.slice(0, 8);
|
||||
};
|
||||
|
||||
const projectName = (id: string | null) => {
|
||||
if (!id) return id?.slice(0, 8) ?? "None";
|
||||
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 currentProjectExecutionWorkspacePolicy =
|
||||
experimentalSettings?.enableIsolatedWorkspaces === true
|
||||
? currentProject?.executionWorkspacePolicy ?? null
|
||||
: null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
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),
|
||||
});
|
||||
const deduplicatedReusableWorkspaces = useMemo(() => {
|
||||
const workspaces = reusableExecutionWorkspaces ?? [];
|
||||
const seen = new Map<string, typeof workspaces[number]>();
|
||||
for (const ws of workspaces) {
|
||||
const key = ws.cwd ?? ws.id;
|
||||
const existing = seen.get(key);
|
||||
if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) {
|
||||
seen.set(key, ws);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}, [reusableExecutionWorkspaces]);
|
||||
const selectedReusableExecutionWorkspace =
|
||||
deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId)
|
||||
?? issue.currentExecutionWorkspace
|
||||
?? null;
|
||||
const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue)
|
||||
? "reuse_existing"
|
||||
: (
|
||||
issue.executionWorkspacePreference
|
||||
?? issue.executionWorkspaceSettings?.mode
|
||||
?? defaultExecutionWorkspaceModeForProject(currentProject)
|
||||
);
|
||||
const projectLink = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
const project = projects?.find((p) => p.id === id) ?? null;
|
||||
return project ? projectUrl(project) : `/projects/${id}`;
|
||||
};
|
||||
|
||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
|
||||
const sortedAgents = useMemo(
|
||||
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
|
||||
[agents, recentAssigneeIds],
|
||||
);
|
||||
|
||||
const assignee = issue.assigneeAgentId
|
||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||
: null;
|
||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
|
||||
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||
|
||||
const labelsTrigger = (issue.labels ?? []).length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border"
|
||||
style={{
|
||||
borderColor: label.color,
|
||||
backgroundColor: `${label.color}22`,
|
||||
color: pickTextColorForPillBg(label.color, 0.13),
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">No labels</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const labelsContent = (
|
||||
<>
|
||||
<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 labels..."
|
||||
value={labelSearch}
|
||||
onChange={(e) => setLabelSearch(e.target.value)}
|
||||
autoFocus={!inline}
|
||||
/>
|
||||
<div className="max-h-44 overflow-y-auto overscroll-contain space-y-0.5">
|
||||
{(labels ?? [])
|
||||
.filter((label) => {
|
||||
if (!labelSearch.trim()) return true;
|
||||
return label.name.toLowerCase().includes(labelSearch.toLowerCase());
|
||||
})
|
||||
.map((label) => {
|
||||
const selected = (issue.labelIds ?? []).includes(label.id);
|
||||
return (
|
||||
<div key={label.id} className="flex items-center gap-1">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 flex-1 px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
||||
selected && "bg-accent"
|
||||
)}
|
||||
onClick={() => toggleLabel(label.id)}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} />
|
||||
<span className="truncate">{label.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-muted-foreground hover:text-destructive rounded"
|
||||
onClick={() => deleteLabel.mutate(label.id)}
|
||||
title={`Delete ${label.name}`}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 border-t border-border pt-2 space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
className="h-7 w-7 p-0 rounded bg-transparent"
|
||||
type="color"
|
||||
value={newLabelColor}
|
||||
onChange={(e) => setNewLabelColor(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-transparent outline-none rounded placeholder:text-muted-foreground/50"
|
||||
placeholder="New label"
|
||||
value={newLabelName}
|
||||
onChange={(e) => setNewLabelName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 w-full px-2 py-1.5 text-xs rounded border border-border hover:bg-accent/50 disabled:opacity-50"
|
||||
disabled={!newLabelName.trim() || createLabel.isPending}
|
||||
onClick={() =>
|
||||
createLabel.mutate({
|
||||
name: newLabelName.trim(),
|
||||
color: newLabelColor,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{createLabel.isPending ? "Creating…" : "Create label"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const assigneeTrigger = assignee ? (
|
||||
<Identity name={assignee.name} size="sm" />
|
||||
) : assigneeUserLabel ? (
|
||||
<>
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm">{assigneeUserLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Unassigned</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const assigneeContent = (
|
||||
<>
|
||||
<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 assignees..."
|
||||
value={assigneeSearch}
|
||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||
autoFocus={!inline}
|
||||
/>
|
||||
<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 && !issue.assigneeUserId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { onUpdate({ assigneeAgentId: null, assigneeUserId: null }); setAssigneeOpen(false); }}
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
{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",
|
||||
issue.assigneeUserId === issue.createdByUserId && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({ assigneeAgentId: null, assigneeUserId: issue.createdByUserId });
|
||||
setAssigneeOpen(false);
|
||||
}}
|
||||
>
|
||||
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
|
||||
</button>
|
||||
)}
|
||||
{sortedAgents
|
||||
.filter((a) => {
|
||||
if (!assigneeSearch.trim()) return true;
|
||||
const q = assigneeSearch.toLowerCase();
|
||||
return a.name.toLowerCase().includes(q);
|
||||
})
|
||||
.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
a.id === issue.assigneeAgentId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { trackRecentAssignee(a.id); onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }}
|
||||
>
|
||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const projectTrigger = issue.projectId ? (
|
||||
<>
|
||||
<span
|
||||
className="shrink-0 h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
|
||||
/>
|
||||
<span className="text-sm truncate">{projectName(issue.projectId)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Hexagon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">No project</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const projectContent = (
|
||||
<>
|
||||
<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 projects..."
|
||||
value={projectSearch}
|
||||
onChange={(e) => setProjectSearch(e.target.value)}
|
||||
autoFocus={!inline}
|
||||
/>
|
||||
<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 whitespace-nowrap",
|
||||
!issue.projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
onUpdate({
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
});
|
||||
setProjectOpen(false);
|
||||
}}
|
||||
>
|
||||
No project
|
||||
</button>
|
||||
{orderedProjects
|
||||
.filter((p) => {
|
||||
if (!projectSearch.trim()) return true;
|
||||
const q = projectSearch.toLowerCase();
|
||||
return p.name.toLowerCase().includes(q);
|
||||
})
|
||||
.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={cn(
|
||||
"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={() => {
|
||||
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"
|
||||
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
||||
/>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Status">
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
onChange={(status) => onUpdate({ status })}
|
||||
showLabel
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Priority">
|
||||
<PriorityIcon
|
||||
priority={issue.priority}
|
||||
onChange={(priority) => onUpdate({ priority })}
|
||||
showLabel
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Labels"
|
||||
open={labelsOpen}
|
||||
onOpenChange={(open) => { setLabelsOpen(open); if (!open) setLabelSearch(""); }}
|
||||
triggerContent={labelsTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName="w-64"
|
||||
>
|
||||
{labelsContent}
|
||||
</PropertyPicker>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Assignee"
|
||||
open={assigneeOpen}
|
||||
onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}
|
||||
triggerContent={assigneeTrigger}
|
||||
popoverClassName="w-52"
|
||||
extra={issue.assigneeAgentId ? (
|
||||
<Link
|
||||
to={`/agents/${issue.assigneeAgentId}`}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : undefined}
|
||||
>
|
||||
{assigneeContent}
|
||||
</PropertyPicker>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Project"
|
||||
open={projectOpen}
|
||||
onOpenChange={(open) => { setProjectOpen(open); if (!open) setProjectSearch(""); }}
|
||||
triggerContent={projectTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName="w-fit min-w-[11rem]"
|
||||
extra={issue.projectId ? (
|
||||
<Link
|
||||
to={projectLink(issue.projectId)!}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : undefined}
|
||||
>
|
||||
{projectContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{currentProjectSupportsExecutionWorkspace && (
|
||||
<PropertyRow label="Workspace">
|
||||
<div className="w-full space-y-2">
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={currentExecutionWorkspaceSelection}
|
||||
onChange={(e) => {
|
||||
const nextMode = e.target.value;
|
||||
onUpdate({
|
||||
executionWorkspacePreference: nextMode,
|
||||
executionWorkspaceId: nextMode === "reuse_existing" ? issue.executionWorkspaceId : null,
|
||||
executionWorkspaceSettings: {
|
||||
mode:
|
||||
nextMode === "reuse_existing"
|
||||
? issueModeForExistingWorkspace(selectedReusableExecutionWorkspace?.mode)
|
||||
: nextMode,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{EXECUTION_WORKSPACE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.value === "reuse_existing" && selectedReusableExecutionWorkspace?.mode === "isolated_workspace"
|
||||
? "Existing isolated workspace"
|
||||
: option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{currentExecutionWorkspaceSelection === "reuse_existing" && (
|
||||
<select
|
||||
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
|
||||
value={issue.executionWorkspaceId ?? ""}
|
||||
onChange={(e) => {
|
||||
const nextExecutionWorkspaceId = e.target.value || null;
|
||||
const nextExecutionWorkspace = deduplicatedReusableWorkspaces.find(
|
||||
(workspace) => workspace.id === nextExecutionWorkspaceId,
|
||||
);
|
||||
onUpdate({
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceId: nextExecutionWorkspaceId,
|
||||
executionWorkspaceSettings: {
|
||||
mode: issueModeForExistingWorkspace(nextExecutionWorkspace?.mode),
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Choose an existing workspace</option>
|
||||
{deduplicatedReusableWorkspaces.map((workspace) => (
|
||||
<option key={workspace.id} value={workspace.id}>
|
||||
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{issue.currentExecutionWorkspace && (
|
||||
<div className="text-[11px] text-muted-foreground space-y-0.5">
|
||||
<div style={{ overflowWrap: "anywhere" }}>
|
||||
Current:{" "}
|
||||
<Link
|
||||
to={`/execution-workspaces/${issue.currentExecutionWorkspace.id}`}
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
<BreakablePath text={issue.currentExecutionWorkspace.name} />
|
||||
</Link>
|
||||
{" · "}
|
||||
{issue.currentExecutionWorkspace.status}
|
||||
</div>
|
||||
{issue.currentExecutionWorkspace.cwd && (
|
||||
<CopyableValue value={issue.currentExecutionWorkspace.cwd} mono className="text-[11px]" />
|
||||
)}
|
||||
{issue.currentExecutionWorkspace.branchName && (
|
||||
<CopyableValue value={issue.currentExecutionWorkspace.branchName} label="Branch:" className="text-[11px]" />
|
||||
)}
|
||||
{issue.currentExecutionWorkspace.repoUrl && (
|
||||
<CopyableValue value={issue.currentExecutionWorkspace.repoUrl} label="Repo:" mono className="text-[11px]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!issue.currentExecutionWorkspace && currentProject?.primaryWorkspace?.cwd && (
|
||||
<CopyableValue value={currentProject.primaryWorkspace.cwd} mono className="text-[11px] text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{issue.parentId && (
|
||||
<PropertyRow label="Parent">
|
||||
<Link
|
||||
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{issue.requestDepth > 0 && (
|
||||
<PropertyRow label="Depth">
|
||||
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.completedAt && (
|
||||
<PropertyRow label="Completed">
|
||||
<span className="text-sm">{formatDate(issue.completedAt)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Created">
|
||||
<span className="text-sm">{formatDate(issue.createdAt)}</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Updated">
|
||||
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
type UnreadState = "hidden" | "visible" | "fading";
|
||||
|
||||
interface IssueRowProps {
|
||||
issue: Issue;
|
||||
issueLinkState?: unknown;
|
||||
mobileLeading?: ReactNode;
|
||||
desktopMetaLeading?: ReactNode;
|
||||
desktopLeadingSpacer?: boolean;
|
||||
mobileMeta?: ReactNode;
|
||||
desktopTrailing?: ReactNode;
|
||||
trailingMeta?: ReactNode;
|
||||
unreadState?: UnreadState | null;
|
||||
onMarkRead?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IssueRow({
|
||||
issue,
|
||||
issueLinkState,
|
||||
mobileLeading,
|
||||
desktopMetaLeading,
|
||||
desktopLeadingSpacer = false,
|
||||
mobileMeta,
|
||||
desktopTrailing,
|
||||
trailingMeta,
|
||||
unreadState = null,
|
||||
onMarkRead,
|
||||
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";
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/issues/${issuePathId}`}
|
||||
state={issueLinkState}
|
||||
className={cn(
|
||||
"flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 pt-px sm:hidden">
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} />}
|
||||
</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} />
|
||||
</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">
|
||||
·
|
||||
</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="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
||||
aria-label="Mark as read"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
import { 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 } from "lucide-react";
|
||||
import { KanbanBoard } from "./KanbanBoard";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||
const priorityOrder = ["critical", "high", "medium", "low"];
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/* ── View state ── */
|
||||
|
||||
export type IssueViewState = {
|
||||
statuses: string[];
|
||||
priorities: string[];
|
||||
assignees: string[];
|
||||
labels: string[];
|
||||
projects: string[];
|
||||
sortField: "status" | "priority" | "title" | "created" | "updated";
|
||||
sortDir: "asc" | "desc";
|
||||
groupBy: "status" | "priority" | "assignee" | "none";
|
||||
viewMode: "list" | "board";
|
||||
collapsedGroups: string[];
|
||||
};
|
||||
|
||||
const defaultViewState: IssueViewState = {
|
||||
statuses: [],
|
||||
priorities: [],
|
||||
assignees: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
groupBy: "none",
|
||||
viewMode: "list",
|
||||
collapsedGroups: [],
|
||||
};
|
||||
|
||||
const quickFilterPresets = [
|
||||
{ label: "All", statuses: [] as string[] },
|
||||
{ label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] },
|
||||
{ label: "Backlog", statuses: ["backlog"] },
|
||||
{ label: "Done", statuses: ["done", "cancelled"] },
|
||||
];
|
||||
|
||||
function getViewState(key: string): IssueViewState {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) return { ...defaultViewState, ...JSON.parse(raw) };
|
||||
} catch { /* ignore */ }
|
||||
return { ...defaultViewState };
|
||||
}
|
||||
|
||||
function saveViewState(key: string, state: IssueViewState) {
|
||||
localStorage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sa = [...a].sort();
|
||||
const sb = [...b].sort();
|
||||
return sa.every((v, i) => v === sb[i]);
|
||||
}
|
||||
|
||||
function toggleInArray(arr: string[], value: string): string[] {
|
||||
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
|
||||
}
|
||||
|
||||
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((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;
|
||||
}
|
||||
|
||||
function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
|
||||
const sorted = [...issues];
|
||||
const dir = state.sortDir === "asc" ? 1 : -1;
|
||||
sorted.sort((a, b) => {
|
||||
switch (state.sortField) {
|
||||
case "status":
|
||||
return dir * (statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status));
|
||||
case "priority":
|
||||
return dir * (priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority));
|
||||
case "title":
|
||||
return dir * a.title.localeCompare(b.title);
|
||||
case "created":
|
||||
return dir * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
case "updated":
|
||||
return dir * (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function countActiveFilters(state: IssueViewState): number {
|
||||
let count = 0;
|
||||
if (state.statuses.length > 0) count++;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const [viewState, setViewState] = useState<IssueViewState>(() => {
|
||||
if (initialAssignees) {
|
||||
return { ...defaultViewState, assignees: initialAssignees, statuses: [] };
|
||||
}
|
||||
return getViewState(scopedKey);
|
||||
});
|
||||
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();
|
||||
|
||||
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(() => {
|
||||
if (prevScopedKey.current !== scopedKey) {
|
||||
prevScopedKey.current = scopedKey;
|
||||
setViewState(initialAssignees
|
||||
? { ...defaultViewState, assignees: initialAssignees, statuses: [] }
|
||||
: getViewState(scopedKey));
|
||||
}
|
||||
}, [scopedKey, initialAssignees]);
|
||||
|
||||
const updateView = useCallback((patch: Partial<IssueViewState>) => {
|
||||
setViewState((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
saveViewState(scopedKey, next);
|
||||
return next;
|
||||
});
|
||||
}, [scopedKey]);
|
||||
|
||||
const { data: searchedIssues = [] } = useQuery({
|
||||
queryKey: [
|
||||
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||
searchFilters ?? {},
|
||||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
|
||||
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||
});
|
||||
|
||||
const agentName = useCallback((id: string | null) => {
|
||||
if (!id || !agents) return null;
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
}, [agents]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||
return sortIssues(filteredByControls, viewState);
|
||||
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
|
||||
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.listLabels(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const activeFilterCount = countActiveFilters(viewState);
|
||||
|
||||
const groupedContent = useMemo(() => {
|
||||
if (viewState.groupBy === "none") {
|
||||
return [{ key: "__all", label: null as string | null, items: filtered }];
|
||||
}
|
||||
if (viewState.groupBy === "status") {
|
||||
const groups = groupBy(filtered, (i) => i.status);
|
||||
return statusOrder
|
||||
.filter((s) => groups[s]?.length)
|
||||
.map((s) => ({ key: s, label: statusLabel(s), items: groups[s]! }));
|
||||
}
|
||||
if (viewState.groupBy === "priority") {
|
||||
const groups = groupBy(filtered, (i) => i.priority);
|
||||
return priorityOrder
|
||||
.filter((p) => groups[p]?.length)
|
||||
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
|
||||
}
|
||||
// assignee
|
||||
const groups = groupBy(
|
||||
filtered,
|
||||
(issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"),
|
||||
);
|
||||
return Object.keys(groups).map((key) => ({
|
||||
key,
|
||||
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, agentName, currentUserId]);
|
||||
|
||||
const newIssueDefaults = (groupKey?: string) => {
|
||||
const defaults: Record<string, string> = {};
|
||||
if (projectId) defaults.projectId = projectId;
|
||||
if (groupKey) {
|
||||
if (viewState.groupBy === "status") defaults.status = groupKey;
|
||||
else if (viewState.groupBy === "priority") defaults.priority = 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, assigneeUserId: string | null = null) => {
|
||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
||||
setAssigneePickerIssueId(null);
|
||||
setAssigneeSearch("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||
<Button size="sm" variant="outline" onClick={() => openNewIssue(newIssueDefaults())}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
||||
{/* View mode toggle */}
|
||||
<div className="flex items-center border border-border rounded-md overflow-hidden mr-1">
|
||||
<button
|
||||
className={`p-1.5 transition-colors ${viewState.viewMode === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => updateView({ viewMode: "list" })}
|
||||
title="List view"
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 transition-colors ${viewState.viewMode === "board" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => updateView({ viewMode: "board" })}
|
||||
title="Board view"
|
||||
>
|
||||
<Columns3 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={`text-xs ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`}>
|
||||
<Filter className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="sm:hidden text-[10px] font-medium ml-0.5">{activeFilterCount}</span>
|
||||
)}
|
||||
{activeFilterCount > 0 && (
|
||||
<X
|
||||
className="h-3 w-3 ml-1 hidden sm:block"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateView({ statuses: [], priorities: [], assignees: [], labels: [], projects: [] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[min(480px,calc(100vw-2rem))] p-0">
|
||||
<div className="p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filters</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => updateView({ statuses: [], priorities: [], assignees: [], labels: [] })}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick filters */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">Quick filters</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{quickFilterPresets.map((preset) => {
|
||||
const isActive = arraysEqual(viewState.statuses, preset.statuses);
|
||||
return (
|
||||
<button
|
||||
key={preset.label}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
|
||||
}`}
|
||||
onClick={() => updateView({ statuses: isActive ? [] : [...preset.statuses] })}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Multi-column filter sections */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
|
||||
{/* Status */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Status</span>
|
||||
<div className="space-y-0.5">
|
||||
{statusOrder.map((s) => (
|
||||
<label key={s} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.statuses.includes(s)}
|
||||
onCheckedChange={() => updateView({ statuses: toggleInArray(viewState.statuses, s) })}
|
||||
/>
|
||||
<StatusIcon status={s} />
|
||||
<span className="text-sm">{statusLabel(s)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority + Assignee stacked in right column */}
|
||||
<div className="space-y-3">
|
||||
{/* Priority */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Priority</span>
|
||||
<div className="space-y-0.5">
|
||||
{priorityOrder.map((p) => (
|
||||
<label key={p} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.priorities.includes(p)}
|
||||
onCheckedChange={() => updateView({ priorities: toggleInArray(viewState.priorities, p) })}
|
||||
/>
|
||||
<PriorityIcon priority={p} />
|
||||
<span className="text-sm">{statusLabel(p)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<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">
|
||||
<span className="text-xs text-muted-foreground">Labels</span>
|
||||
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{labels.map((label) => (
|
||||
<label key={label.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.labels.includes(label.id)}
|
||||
onCheckedChange={() => updateView({ labels: toggleInArray(viewState.labels, label.id) })}
|
||||
/>
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: label.color }} />
|
||||
<span className="text-sm">{label.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Sort (list view only) */}
|
||||
{viewState.viewMode === "list" && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs">
|
||||
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Sort</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-48 p-0">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{([
|
||||
["status", "Status"],
|
||||
["priority", "Priority"],
|
||||
["title", "Title"],
|
||||
["created", "Created"],
|
||||
["updated", "Updated"],
|
||||
] as const).map(([field, label]) => (
|
||||
<button
|
||||
key={field}
|
||||
className={`flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-sm ${
|
||||
viewState.sortField === field ? "bg-accent/50 text-foreground" : "hover:bg-accent/50 text-muted-foreground"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (viewState.sortField === field) {
|
||||
updateView({ sortDir: viewState.sortDir === "asc" ? "desc" : "asc" });
|
||||
} else {
|
||||
updateView({ sortField: field, sortDir: "asc" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{viewState.sortField === field && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{viewState.sortDir === "asc" ? "\u2191" : "\u2193"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Group (list view only) */}
|
||||
{viewState.viewMode === "list" && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs">
|
||||
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Group</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{([
|
||||
["status", "Status"],
|
||||
["priority", "Priority"],
|
||||
["assignee", "Assignee"],
|
||||
["none", "None"],
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
className={`flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-sm ${
|
||||
viewState.groupBy === value ? "bg-accent/50 text-foreground" : "hover:bg-accent/50 text-muted-foreground"
|
||||
}`}
|
||||
onClick={() => updateView({ groupBy: value })}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{viewState.groupBy === value && <Check className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && <PageSkeleton variant="issues-list" />}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
||||
<EmptyState
|
||||
icon={CircleDot}
|
||||
message="No issues match the current filters or search."
|
||||
action="Create Issue"
|
||||
onAction={() => openNewIssue(newIssueDefaults())}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewState.viewMode === "board" ? (
|
||||
<KanbanBoard
|
||||
issues={filtered}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
onUpdateIssue={onUpdateIssue}
|
||||
/>
|
||||
) : (
|
||||
groupedContent.map((group) => (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={!viewState.collapsedGroups.includes(group.key)}
|
||||
onOpenChange={(open) => {
|
||||
updateView({
|
||||
collapsedGroups: open
|
||||
? viewState.collapsedGroups.filter((k) => k !== group.key)
|
||||
: [...viewState.collapsedGroups, group.key],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{group.label && (
|
||||
<div className="flex items-center py-1.5 pl-1 pr-3">
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{group.label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-auto text-muted-foreground"
|
||||
onClick={() => openNewIssue(newIssueDefaults(group.key))}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
{group.items.map((issue) => (
|
||||
<IssueRow
|
||||
key={issue.id}
|
||||
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>
|
||||
)}
|
||||
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>
|
||||
<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) => {
|
||||
setAssigneePickerIssueId(open ? issue.id : null);
|
||||
if (!open) setAssigneeSearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
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();
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
<User className="h-3 w-3" />
|
||||
</span>
|
||||
Assignee
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-1"
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||
>
|
||||
<input
|
||||
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
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<button
|
||||
className={cn(
|
||||
"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, 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());
|
||||
})
|
||||
.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"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, null);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
trailingMeta={formatDate(issue.createdAt)}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,274 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
const boardStatuses = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"blocked",
|
||||
"done",
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface KanbanBoardProps {
|
||||
issues: Issue[];
|
||||
agents?: Agent[];
|
||||
liveIssueIds?: Set<string>;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/* ── Droppable Column ── */
|
||||
|
||||
function KanbanColumn({
|
||||
status,
|
||||
issues,
|
||||
agents,
|
||||
liveIssueIds,
|
||||
}: {
|
||||
status: string;
|
||||
issues: Issue[];
|
||||
agents?: Agent[];
|
||||
liveIssueIds?: Set<string>;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-[260px] w-[260px] shrink-0">
|
||||
<div className="flex items-center gap-2 px-2 py-2 mb-1">
|
||||
<StatusIcon status={status} />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60 ml-auto tabular-nums">
|
||||
{issues.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex-1 min-h-[120px] rounded-md p-1 space-y-1 transition-colors ${
|
||||
isOver ? "bg-accent/40" : "bg-muted/20"
|
||||
}`}
|
||||
>
|
||||
<SortableContext
|
||||
items={issues.map((i) => i.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{issues.map((issue) => (
|
||||
<KanbanCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
agents={agents}
|
||||
isLive={liveIssueIds?.has(issue.id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Draggable Card ── */
|
||||
|
||||
function KanbanCard({
|
||||
issue,
|
||||
agents,
|
||||
isLive,
|
||||
isOverlay,
|
||||
}: {
|
||||
issue: Issue;
|
||||
agents?: Agent[];
|
||||
isLive?: boolean;
|
||||
isOverlay?: boolean;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: issue.id, data: { issue } });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id || !agents) return null;
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`rounded-md border bg-card p-2.5 cursor-grab active:cursor-grabbing transition-shadow ${
|
||||
isDragging && !isOverlay ? "opacity-30" : ""
|
||||
} ${isOverlay ? "shadow-lg ring-1 ring-primary/20" : "hover:shadow-sm"}`}
|
||||
>
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="block no-underline text-inherit"
|
||||
onClick={(e) => {
|
||||
// Prevent navigation during drag
|
||||
if (isDragging) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-1.5 mb-1.5">
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{isLive && (
|
||||
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-snug line-clamp-2 mb-2">{issue.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name ? (
|
||||
<Identity name={name} size="xs" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{issue.assigneeAgentId.slice(0, 8)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Board ── */
|
||||
|
||||
export function KanbanBoard({
|
||||
issues,
|
||||
agents,
|
||||
liveIssueIds,
|
||||
onUpdateIssue,
|
||||
}: KanbanBoardProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||
);
|
||||
|
||||
const columnIssues = useMemo(() => {
|
||||
const grouped: Record<string, Issue[]> = {};
|
||||
for (const status of boardStatuses) {
|
||||
grouped[status] = [];
|
||||
}
|
||||
for (const issue of issues) {
|
||||
if (grouped[issue.status]) {
|
||||
grouped[issue.status].push(issue);
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}, [issues]);
|
||||
|
||||
const activeIssue = useMemo(
|
||||
() => (activeId ? issues.find((i) => i.id === activeId) : null),
|
||||
[activeId, issues]
|
||||
);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveId(event.active.id as string);
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const issueId = active.id as string;
|
||||
const issue = issues.find((i) => i.id === issueId);
|
||||
if (!issue) return;
|
||||
|
||||
// Determine target status: the "over" could be a column id (status string)
|
||||
// or another card's id. Find which column the "over" belongs to.
|
||||
let targetStatus: string | null = null;
|
||||
|
||||
if (boardStatuses.includes(over.id as string)) {
|
||||
targetStatus = over.id as string;
|
||||
} else {
|
||||
// It's a card - find which column it's in
|
||||
const targetIssue = issues.find((i) => i.id === over.id);
|
||||
if (targetIssue) {
|
||||
targetStatus = targetIssue.status;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetStatus && targetStatus !== issue.status) {
|
||||
onUpdateIssue(issueId, { status: targetStatus });
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(_event: DragOverEvent) {
|
||||
// Could be used for visual feedback; keeping simple for now
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-3 overflow-x-auto pb-4 -mx-2 px-2">
|
||||
{boardStatuses.map((status) => (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issues={columnIssues[status] ?? []}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeIssue ? (
|
||||
<KanbanCard issue={activeIssue} agents={agents} isOverlay />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 { InstanceSidebar } from "./InstanceSidebar";
|
||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { NewIssueDialog } from "./NewIssueDialog";
|
||||
import { NewProjectDialog } from "./NewProjectDialog";
|
||||
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";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
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,
|
||||
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(() => {
|
||||
if (companiesLoading || onboardingTriggered.current) return;
|
||||
if (health?.deploymentMode === "authenticated") return;
|
||||
if (companies.length === 0) {
|
||||
onboardingTriggered.current = true;
|
||||
openOnboarding();
|
||||
}
|
||||
}, [companies, companiesLoading, openOnboarding, health?.deploymentMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyPrefix || companiesLoading || companies.length === 0) return;
|
||||
|
||||
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 !== matchedCompany.issuePrefix) {
|
||||
const suffix = location.pathname.replace(/^\/[^/]+/, "");
|
||||
navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
useCompanyPageMemory();
|
||||
|
||||
useKeyboardShortcuts({
|
||||
onNewIssue: () => openNewIssue(),
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onTogglePanel: togglePanel,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
setMobileNavVisible(true);
|
||||
return;
|
||||
}
|
||||
lastMainScrollTop.current = 0;
|
||||
setMobileNavVisible(true);
|
||||
}, [isMobile]);
|
||||
|
||||
// Swipe gesture to open/close sidebar on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
|
||||
const EDGE_ZONE = 30; // px from left edge to start open-swipe
|
||||
const MIN_DISTANCE = 50; // minimum horizontal swipe distance
|
||||
const MAX_VERTICAL = 75; // max vertical drift before we ignore
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
const t = e.touches[0]!;
|
||||
startX = t.clientX;
|
||||
startY = t.clientY;
|
||||
};
|
||||
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
const t = e.changedTouches[0]!;
|
||||
const dx = t.clientX - startX;
|
||||
const dy = Math.abs(t.clientY - startY);
|
||||
|
||||
if (dy > MAX_VERTICAL) return; // vertical scroll, ignore
|
||||
|
||||
// Swipe right from left edge → open
|
||||
if (!sidebarOpen && startX < EDGE_ZONE && dx > MIN_DISTANCE) {
|
||||
setSidebarOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Swipe left when open → close
|
||||
if (sidebarOpen && dx < -MIN_DISTANCE) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
document.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("touchstart", onTouchStart);
|
||||
document.removeEventListener("touchend", onTouchEnd);
|
||||
};
|
||||
}, [isMobile, sidebarOpen, setSidebarOpen]);
|
||||
|
||||
const updateMobileNavVisibility = useCallback((currentTop: number) => {
|
||||
const delta = currentTop - lastMainScrollTop.current;
|
||||
|
||||
if (currentTop <= 24) {
|
||||
setMobileNavVisible(true);
|
||||
} else if (delta > 8) {
|
||||
setMobileNavVisible(false);
|
||||
} else if (delta < -8) {
|
||||
setMobileNavVisible(true);
|
||||
}
|
||||
|
||||
lastMainScrollTop.current = currentTop;
|
||||
}, []);
|
||||
|
||||
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={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>
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 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(
|
||||
"flex-1 p-4 md:p-6",
|
||||
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
||||
)}
|
||||
>
|
||||
{hasUnknownCompanyPrefix ? (
|
||||
<NotFoundPage
|
||||
scope="invalid_company_prefix"
|
||||
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
|
||||
/>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
</main>
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
||||
<CommandPalette />
|
||||
<NewIssueDialog />
|
||||
<NewProjectDialog />
|
||||
<NewGoalDialog />
|
||||
<NewAgentDialog />
|
||||
<ToastViewport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
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;
|
||||
}
|
||||
|
||||
function toIsoString(value: string | Date | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
return typeof value === "string" ? value : value.toISOString();
|
||||
}
|
||||
|
||||
function isRunActive(status: string): boolean {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [cancellingRunIds, setCancellingRunIds] = useState(new Set<string>());
|
||||
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
|
||||
enabled: !!issueId,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const { data: activeRun } = useQuery({
|
||||
queryKey: queryKeys.issues.activeRun(issueId),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
|
||||
enabled: !!issueId,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const runs = useMemo(() => {
|
||||
const deduped = new Map<string, LiveRunForIssue>();
|
||||
for (const run of liveRuns ?? []) {
|
||||
deduped.set(run.id, run);
|
||||
}
|
||||
if (activeRun) {
|
||||
deduped.set(activeRun.id, {
|
||||
id: activeRun.id,
|
||||
status: activeRun.status,
|
||||
invocationSource: activeRun.invocationSource,
|
||||
triggerDetail: activeRun.triggerDetail,
|
||||
startedAt: toIsoString(activeRun.startedAt),
|
||||
finishedAt: toIsoString(activeRun.finishedAt),
|
||||
createdAt: toIsoString(activeRun.createdAt) ?? new Date().toISOString(),
|
||||
agentId: activeRun.agentId,
|
||||
agentName: activeRun.agentName,
|
||||
adapterType: activeRun.adapterType,
|
||||
issueId,
|
||||
});
|
||||
}
|
||||
return [...deduped.values()].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
}, [activeRun, issueId, liveRuns]);
|
||||
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs, companyId });
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (runs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>{""}</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('<img src="/api/attachments/test/content" alt=""/>');
|
||||
});
|
||||
|
||||
it("resolves relative image paths when a resolver is provided", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
|
||||
{""}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('src="/resolved/images/org-chart.png"');
|
||||
expect(html).toContain('alt="Org chart"');
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1081 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user