mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
Merge pull request #2218 from HenkDz/feat/external-adapter-phase1
feat(adapters): external adapter plugin system with dynamic UI parser
This commit is contained in:
commit
35f2fc7230
87 changed files with 5819 additions and 605 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||
import type {
|
||||
Agent,
|
||||
AdapterEnvironmentTestResult,
|
||||
|
|
@ -46,6 +45,9 @@ import { ChoosePathButton } from "./PathInstructionsModal";
|
|||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import { ReportsToPicker } from "./ReportsToPicker";
|
||||
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
|
||||
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
|
||||
/* ---- Create mode values ---- */
|
||||
|
||||
|
|
@ -180,6 +182,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Sync disabled adapter types from server so dropdown filters them out
|
||||
const disabledTypes = useDisabledAdaptersSync();
|
||||
|
||||
const { data: availableSecrets = [] } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
|
||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||
|
|
@ -311,15 +316,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const adapterType = isCreate
|
||||
? props.values.adapterType
|
||||
: overlay.adapterType ?? props.agent.adapterType;
|
||||
const isLocal =
|
||||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
const isHermesLocal = adapterType === "hermes_local";
|
||||
const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]);
|
||||
const isLocal = !NONLOCAL_TYPES.has(adapterType);
|
||||
|
||||
const showLegacyWorkingDirectoryField =
|
||||
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||
|
|
@ -345,13 +344,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
: ["agents", "none", "detect-model", adapterType],
|
||||
queryFn: () => {
|
||||
if (!selectedCompanyId) {
|
||||
throw new Error("Select a company to detect the Hermes model");
|
||||
throw new Error("Select a company to detect the model");
|
||||
}
|
||||
return agentsApi.detectModel(selectedCompanyId, adapterType);
|
||||
},
|
||||
enabled: Boolean(selectedCompanyId && isHermesLocal),
|
||||
enabled: Boolean(selectedCompanyId && isLocal),
|
||||
});
|
||||
const detectedModel = detectedModelData?.model ?? null;
|
||||
const detectedModelCandidates = detectedModelData?.candidates ?? [];
|
||||
|
||||
const { data: companyAgents = [] } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
|
||||
|
|
@ -583,6 +583,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
<Field label="Adapter type" hint={help.adapterType}>
|
||||
<AdapterTypeDropdown
|
||||
value={adapterType}
|
||||
disabledTypes={disabledTypes}
|
||||
onChange={(t) => {
|
||||
if (isCreate) {
|
||||
// Reset all adapter-specific fields to defaults when switching adapter type
|
||||
|
|
@ -692,8 +693,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Adapter-specific fields */}
|
||||
<uiAdapter.ConfigFields {...adapterFieldProps} />
|
||||
{/* Adapter-specific fields are rendered inside Permissions & Configuration */}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -716,24 +716,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ command: v })
|
||||
: mark("adapterConfig", "command", v || undefined)
|
||||
: mark("adapterConfig", "command", v || null)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder={
|
||||
adapterType === "codex_local"
|
||||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
? "agent"
|
||||
: adapterType === "opencode_local"
|
||||
? "opencode"
|
||||
: "claude"
|
||||
({
|
||||
claude_local: "claude",
|
||||
codex_local: "codex",
|
||||
gemini_local: "gemini",
|
||||
pi_local: "pi",
|
||||
cursor: "agent",
|
||||
opencode_local: "opencode",
|
||||
} as Record<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
|
@ -748,18 +743,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
}
|
||||
open={modelOpen}
|
||||
onOpenChange={setModelOpen}
|
||||
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
|
||||
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
|
||||
allowDefault={adapterType !== "opencode_local"}
|
||||
required={adapterType === "opencode_local"}
|
||||
groupByProvider={adapterType === "opencode_local"}
|
||||
creatable={adapterType === "hermes_local"}
|
||||
detectedModel={adapterType === "hermes_local" ? detectedModel : null}
|
||||
onDetectModel={adapterType === "hermes_local"
|
||||
? async () => {
|
||||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}
|
||||
: undefined}
|
||||
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
|
||||
creatable
|
||||
detectedModel={detectedModel}
|
||||
detectedModelCandidates={[]}
|
||||
onDetectModel={async () => {
|
||||
const result = await refetchDetectedModel();
|
||||
return result.data?.model ?? null;
|
||||
}}
|
||||
detectModelLabel="Detect model"
|
||||
emptyDetectHint="No model detected. Select or enter one manually."
|
||||
/>
|
||||
{fetchedModelsError && (
|
||||
<p className="text-xs text-destructive">
|
||||
|
|
@ -820,6 +815,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
{adapterType === "claude_local" && (
|
||||
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
||||
)}
|
||||
<uiAdapter.ConfigFields {...adapterFieldProps} />
|
||||
|
||||
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
|
||||
<DraftInput
|
||||
|
|
@ -831,7 +827,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ extraArgs: v })
|
||||
: mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : undefined)
|
||||
: mark("adapterConfig", "extraArgs", v ? parseCommaArgs(v) : null)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
|
|
@ -1024,37 +1020,37 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||
|
||||
/* ---- Internal sub-components ---- */
|
||||
|
||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
|
||||
|
||||
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||
...AGENT_ADAPTER_TYPES.map((t) => ({
|
||||
value: t,
|
||||
label: adapterLabels[t] ?? t,
|
||||
comingSoon: !ENABLED_ADAPTER_TYPES.has(t),
|
||||
})),
|
||||
];
|
||||
|
||||
function AdapterTypeDropdown({
|
||||
value,
|
||||
onChange,
|
||||
disabledTypes,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (type: string) => void;
|
||||
disabledTypes: Set<string>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const adapterList = useMemo(
|
||||
() =>
|
||||
listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter(
|
||||
(item) => !disabledTypes.has(item.value),
|
||||
),
|
||||
[disabledTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||
<span>{adapterLabels[value] ?? value}</span>
|
||||
<span>{adapterLabels[value] ?? getAdapterLabel(value)}</span>
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||
{ADAPTER_DISPLAY_LIST.map((item) => (
|
||||
{adapterList.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
disabled={item.comingSoon}
|
||||
|
|
@ -1066,7 +1062,10 @@ function AdapterTypeDropdown({
|
|||
item.value === value && !item.comingSoon && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!item.comingSoon) onChange(item.value);
|
||||
if (!item.comingSoon) {
|
||||
onChange(item.value);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
|
|
@ -1357,8 +1356,10 @@ function ModelDropdown({
|
|||
groupByProvider,
|
||||
creatable,
|
||||
detectedModel,
|
||||
detectedModelCandidates,
|
||||
onDetectModel,
|
||||
detectModelLabel,
|
||||
emptyDetectHint,
|
||||
}: {
|
||||
models: AdapterModel[];
|
||||
value: string;
|
||||
|
|
@ -1370,8 +1371,10 @@ function ModelDropdown({
|
|||
groupByProvider: boolean;
|
||||
creatable?: boolean;
|
||||
detectedModel?: string | null;
|
||||
detectedModelCandidates?: string[];
|
||||
onDetectModel?: () => Promise<string | null>;
|
||||
detectModelLabel?: string;
|
||||
emptyDetectHint?: string;
|
||||
}) {
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const [detectingModel, setDetectingModel] = useState(false);
|
||||
|
|
@ -1382,8 +1385,19 @@ function ModelDropdown({
|
|||
manualModel &&
|
||||
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
|
||||
);
|
||||
// Model IDs already shown as detected/candidate badges — exclude from regular list
|
||||
const promotedModelIds = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
if (detectedModel) set.add(detectedModel);
|
||||
for (const c of detectedModelCandidates ?? []) {
|
||||
if (c) set.add(c);
|
||||
}
|
||||
return set;
|
||||
}, [detectedModel, detectedModelCandidates]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
return models.filter((m) => {
|
||||
if (promotedModelIds.has(m.id)) return false;
|
||||
if (!modelSearch.trim()) return true;
|
||||
const q = modelSearch.toLowerCase();
|
||||
const provider = extractProviderId(m.id) ?? "";
|
||||
|
|
@ -1393,7 +1407,7 @@ function ModelDropdown({
|
|||
provider.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [models, modelSearch]);
|
||||
}, [models, modelSearch, promotedModelIds]);
|
||||
const groupedModels = useMemo(() => {
|
||||
if (!groupByProvider) {
|
||||
return [
|
||||
|
|
@ -1474,7 +1488,7 @@ function ModelDropdown({
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDetectModel && !detectedModel && !modelSearch.trim() && (
|
||||
{onDetectModel && !modelSearch.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
||||
|
|
@ -1487,10 +1501,10 @@ function ModelDropdown({
|
|||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
{detectingModel ? "Detecting..." : (detectModelLabel ?? "Detect from config")}
|
||||
{detectingModel ? "Detecting..." : detectedModel ? (detectModelLabel?.replace(/^Detect\b/, "Re-detect") ?? "Re-detect from config") : (detectModelLabel ?? "Detect from config")}
|
||||
</button>
|
||||
)}
|
||||
{value && !models.some((m) => m.id === value) && (
|
||||
{value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
|
@ -1501,7 +1515,7 @@ function ModelDropdown({
|
|||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
|
||||
{value}
|
||||
{models.find((m) => m.id === value)?.label ?? value}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
|
||||
current
|
||||
|
|
@ -1520,13 +1534,38 @@ function ModelDropdown({
|
|||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
|
||||
{detectedModel}
|
||||
{models.find((m) => m.id === detectedModel)?.label ?? detectedModel}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
|
||||
detected
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{detectedModelCandidates
|
||||
?.filter((candidate) => candidate && candidate !== detectedModel && candidate !== value)
|
||||
.map((candidate) => {
|
||||
const entry = models.find((m) => m.id === candidate);
|
||||
return (
|
||||
<button
|
||||
key={`detected-${candidate}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(candidate);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span className="block w-full text-left truncate font-mono text-xs" title={candidate}>
|
||||
{entry?.label ?? candidate}
|
||||
</span>
|
||||
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-sky-500/15 text-sky-400 border border-sky-500/20">
|
||||
config
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{allowDefault && (
|
||||
<button
|
||||
|
|
@ -1584,11 +1623,11 @@ function ModelDropdown({
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{filteredModels.length === 0 && !canCreateManualModel && (
|
||||
{filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{onDetectModel
|
||||
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
|
||||
? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.")
|
||||
: "No models found."}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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 { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { Identity } from "./Identity";
|
||||
|
|
@ -14,17 +15,6 @@ interface AgentPropertiesProps {
|
|||
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 }) {
|
||||
|
|
@ -62,7 +52,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
|||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Adapter">
|
||||
<span className="text-sm font-mono">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
|
||||
<span className="text-sm font-mono">{getAdapterLabel(agent.adapterType)}</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Clock3, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
|
||||
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
|
@ -26,6 +26,7 @@ export function InstanceSidebar() {
|
|||
<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} />
|
||||
<SidebarNavItem to="/instance/settings/adapters" label="Adapters" icon={Cpu} />
|
||||
{(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) => (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useState, type ComponentType } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { adaptersApi } from "../api/adapters";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -13,91 +14,37 @@ import { Button } from "@/components/ui/button";
|
|||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Code,
|
||||
Gem,
|
||||
MousePointer2,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import { HermesIcon } from "./HermesIcon";
|
||||
import { listUIAdapters } from "../adapters";
|
||||
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
|
||||
type AdvancedAdapterType =
|
||||
| "claude_local"
|
||||
| "codex_local"
|
||||
| "gemini_local"
|
||||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
| "openclaw_gateway"
|
||||
| "hermes_local";
|
||||
/**
|
||||
* Adapter types that are suitable for agent creation (excludes internal
|
||||
* system adapters like "process" and "http").
|
||||
*/
|
||||
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
|
||||
|
||||
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||
value: AdvancedAdapterType;
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
recommended?: boolean;
|
||||
}> = [
|
||||
{
|
||||
value: "claude_local",
|
||||
label: "Claude Code",
|
||||
icon: Sparkles,
|
||||
desc: "Local Claude agent",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
value: "codex_local",
|
||||
label: "Codex",
|
||||
icon: Code,
|
||||
desc: "Local Codex agent",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
value: "gemini_local",
|
||||
label: "Gemini CLI",
|
||||
icon: Gem,
|
||||
desc: "Local Gemini agent",
|
||||
},
|
||||
{
|
||||
value: "opencode_local",
|
||||
label: "OpenCode",
|
||||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "hermes_local",
|
||||
label: "Hermes Agent",
|
||||
icon: HermesIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "pi_local",
|
||||
label: "Pi",
|
||||
icon: Terminal,
|
||||
desc: "Local Pi agent",
|
||||
},
|
||||
{
|
||||
value: "cursor",
|
||||
label: "Cursor",
|
||||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent",
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway",
|
||||
label: "OpenClaw Gateway",
|
||||
icon: Bot,
|
||||
desc: "Invoke OpenClaw via gateway protocol",
|
||||
},
|
||||
];
|
||||
function isAgentAdapterType(type: string): boolean {
|
||||
return !SYSTEM_ADAPTER_TYPES.has(type);
|
||||
}
|
||||
|
||||
export function NewAgentDialog() {
|
||||
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const navigate = useNavigate();
|
||||
const [showAdvancedCards, setShowAdvancedCards] = useState(false);
|
||||
const disabledTypes = useDisabledAdaptersSync();
|
||||
|
||||
// Fetch registered adapters from server (syncs disabled store + provides data)
|
||||
const { data: serverAdapters } = useQuery({
|
||||
queryKey: queryKeys.adapters.all,
|
||||
queryFn: () => adaptersApi.list(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Fetch existing agents for the "Ask CEO" flow
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
|
|
@ -106,6 +53,33 @@ export function NewAgentDialog() {
|
|||
|
||||
const ceoAgent = (agents ?? []).find((a) => a.role === "ceo");
|
||||
|
||||
// Build the adapter grid from the UI registry merged with display metadata.
|
||||
// This automatically includes external/plugin adapters.
|
||||
const adapterGrid = useMemo(() => {
|
||||
const registered = listUIAdapters()
|
||||
.filter((a) => isAgentAdapterType(a.type) && !disabledTypes.has(a.type));
|
||||
|
||||
// Sort: recommended first, then alphabetical
|
||||
return registered
|
||||
.map((a) => {
|
||||
const display = getAdapterDisplay(a.type);
|
||||
return {
|
||||
value: a.type,
|
||||
label: display.label,
|
||||
desc: display.description,
|
||||
icon: display.icon,
|
||||
recommended: display.recommended,
|
||||
comingSoon: display.comingSoon,
|
||||
disabledLabel: display.disabledLabel,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.recommended && !b.recommended) return -1;
|
||||
if (!a.recommended && b.recommended) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [disabledTypes, serverAdapters]);
|
||||
|
||||
function handleAskCeo() {
|
||||
closeNewAgent();
|
||||
openNewIssue({
|
||||
|
|
@ -119,7 +93,7 @@ export function NewAgentDialog() {
|
|||
setShowAdvancedCards(true);
|
||||
}
|
||||
|
||||
function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) {
|
||||
function handleAdvancedAdapterPick(adapterType: string) {
|
||||
closeNewAgent();
|
||||
setShowAdvancedCards(false);
|
||||
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
|
||||
|
|
@ -161,7 +135,7 @@ export function NewAgentDialog() {
|
|||
{/* Recommendation */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
||||
<Sparkles className="h-6 w-6 text-foreground" />
|
||||
<Bot className="h-6 w-6 text-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We recommend letting your CEO handle agent setup — they know the
|
||||
|
|
@ -201,13 +175,18 @@ export function NewAgentDialog() {
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ADVANCED_ADAPTER_OPTIONS.map((opt) => (
|
||||
{adapterGrid.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative"
|
||||
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative",
|
||||
opt.comingSoon && "opacity-40 cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => handleAdvancedAdapterPick(opt.value)}
|
||||
disabled={!!opt.comingSoon}
|
||||
title={opt.comingSoon ? opt.disabledLabel : undefined}
|
||||
onClick={() => {
|
||||
if (!opt.comingSoon) handleAdvancedAdapterPick(opt.value);
|
||||
}}
|
||||
>
|
||||
{opt.recommended && (
|
||||
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import {
|
|||
extractProviderIdWithFallback
|
||||
} from "../lib/model-utils";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { listUIAdapters } from "../adapters";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
|
||||
import {
|
||||
|
|
@ -38,37 +41,22 @@ import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
|||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import {
|
||||
Building2,
|
||||
Bot,
|
||||
Code,
|
||||
Gem,
|
||||
ListTodo,
|
||||
Rocket,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Terminal,
|
||||
Sparkles,
|
||||
MousePointer2,
|
||||
Check,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { HermesIcon } from "./HermesIcon";
|
||||
|
||||
|
||||
type Step = 1 | 2 | 3 | 4;
|
||||
type AdapterType =
|
||||
| "claude_local"
|
||||
| "codex_local"
|
||||
| "gemini_local"
|
||||
| "hermes_local"
|
||||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
| "http"
|
||||
| "openclaw_gateway";
|
||||
type AdapterType = string;
|
||||
|
||||
const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
|
||||
|
||||
|
|
@ -85,6 +73,9 @@ export function OnboardingWizard() {
|
|||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||
const [routeDismissed, setRouteDismissed] = useState(false);
|
||||
|
||||
// Sync disabled adapter types from server so adapter grid filters them out
|
||||
const disabledTypes = useDisabledAdaptersSync();
|
||||
|
||||
const routeOnboardingOptions =
|
||||
companyPrefix && companiesLoading
|
||||
? null
|
||||
|
|
@ -206,29 +197,33 @@ export function OnboardingWizard() {
|
|||
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
||||
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
|
||||
});
|
||||
const isLocalAdapter =
|
||||
adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor";
|
||||
const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]);
|
||||
const isLocalAdapter = !NONLOCAL_TYPES.has(adapterType);
|
||||
|
||||
// Build adapter grids dynamically from the UI registry + display metadata.
|
||||
// External/plugin adapters automatically appear with generic defaults.
|
||||
const { recommendedAdapters, moreAdapters } = useMemo(() => {
|
||||
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
|
||||
const all = listUIAdapters()
|
||||
.filter((a) => !SYSTEM_ADAPTER_TYPES.has(a.type) && !disabledTypes.has(a.type))
|
||||
.map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }));
|
||||
|
||||
return {
|
||||
recommendedAdapters: all.filter((a) => a.recommended),
|
||||
moreAdapters: all.filter((a) => !a.recommended),
|
||||
};
|
||||
}, [disabledTypes]);
|
||||
const COMMAND_PLACEHOLDERS: Record<string, string> = {
|
||||
claude_local: "claude",
|
||||
codex_local: "codex",
|
||||
gemini_local: "gemini",
|
||||
pi_local: "pi",
|
||||
cursor: "agent",
|
||||
opencode_local: "opencode",
|
||||
};
|
||||
const effectiveAdapterCommand =
|
||||
command.trim() ||
|
||||
(adapterType === "codex_local"
|
||||
? "codex"
|
||||
: adapterType === "gemini_local"
|
||||
? "gemini"
|
||||
: adapterType === "hermes_local"
|
||||
? "hermes"
|
||||
: adapterType === "pi_local"
|
||||
? "pi"
|
||||
: adapterType === "cursor"
|
||||
? "agent"
|
||||
: adapterType === "opencode_local"
|
||||
? "opencode"
|
||||
: "claude");
|
||||
(COMMAND_PLACEHOLDERS[adapterType] ?? adapterType.replace(/_local$/, ""));
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 2) return;
|
||||
|
|
@ -759,32 +754,17 @@ export function OnboardingWizard() {
|
|||
Adapter type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{
|
||||
value: "claude_local" as const,
|
||||
label: "Claude Code",
|
||||
icon: Sparkles,
|
||||
desc: "Local Claude agent",
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
value: "codex_local" as const,
|
||||
label: "Codex",
|
||||
icon: Code,
|
||||
desc: "Local Codex agent",
|
||||
recommended: true
|
||||
}
|
||||
].map((opt) => (
|
||||
{recommendedAdapters.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
key={opt.type}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
|
||||
adapterType === opt.value
|
||||
adapterType === opt.type
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
const nextType = opt.value as AdapterType;
|
||||
const nextType = opt.type;
|
||||
setAdapterType(nextType);
|
||||
if (nextType === "codex_local" && !model) {
|
||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||
|
|
@ -802,7 +782,7 @@ export function OnboardingWizard() {
|
|||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.desc}
|
||||
{opt.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -823,60 +803,21 @@ export function OnboardingWizard() {
|
|||
|
||||
{showMoreAdapters && (
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
{
|
||||
value: "gemini_local" as const,
|
||||
label: "Gemini CLI",
|
||||
icon: Gem,
|
||||
desc: "Local Gemini agent"
|
||||
},
|
||||
{
|
||||
value: "opencode_local" as const,
|
||||
label: "OpenCode",
|
||||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent"
|
||||
},
|
||||
{
|
||||
value: "pi_local" as const,
|
||||
label: "Pi",
|
||||
icon: Terminal,
|
||||
desc: "Local Pi agent"
|
||||
},
|
||||
{
|
||||
value: "cursor" as const,
|
||||
label: "Cursor",
|
||||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent"
|
||||
},
|
||||
{
|
||||
value: "hermes_local" as const,
|
||||
label: "Hermes Agent",
|
||||
icon: HermesIcon,
|
||||
desc: "Local multi-provider agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway" as const,
|
||||
label: "OpenClaw Gateway",
|
||||
icon: Bot,
|
||||
desc: "Invoke OpenClaw via gateway protocol",
|
||||
comingSoon: true,
|
||||
disabledLabel: "Configure OpenClaw within the App"
|
||||
}
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
disabled={!!opt.comingSoon}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
|
||||
opt.comingSoon
|
||||
? "border-border opacity-40 cursor-not-allowed"
|
||||
: adapterType === opt.value
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (opt.comingSoon) return;
|
||||
const nextType = opt.value as AdapterType;
|
||||
{moreAdapters.map((opt) => (
|
||||
<button
|
||||
key={opt.type}
|
||||
disabled={!!opt.comingSoon}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
|
||||
opt.comingSoon
|
||||
? "border-border opacity-40 cursor-not-allowed"
|
||||
: adapterType === opt.type
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (opt.comingSoon) return;
|
||||
const nextType = opt.type;
|
||||
setAdapterType(nextType);
|
||||
if (nextType === "gemini_local" && !model) {
|
||||
setModel(DEFAULT_GEMINI_LOCAL_MODEL);
|
||||
|
|
@ -899,9 +840,8 @@ export function OnboardingWizard() {
|
|||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.comingSoon
|
||||
? (opt as { disabledLabel?: string })
|
||||
.disabledLabel ?? "Coming soon"
|
||||
: opt.desc}
|
||||
? opt.disabledLabel ?? "Coming soon"
|
||||
: opt.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -910,13 +850,7 @@ export function OnboardingWizard() {
|
|||
</div>
|
||||
|
||||
{/* Conditional adapter fields */}
|
||||
{(adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "gemini_local" ||
|
||||
adapterType === "hermes_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor") && (
|
||||
{isLocalAdapter && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
|
|
|
|||
38
ui/src/components/RunInvocationCard.test.tsx
Normal file
38
ui/src/components/RunInvocationCard.test.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { RunInvocationCard } from "../pages/AgentDetail";
|
||||
|
||||
describe("RunInvocationCard", () => {
|
||||
it("keeps verbose invocation details collapsed by default", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<RunInvocationCard
|
||||
payload={{
|
||||
adapterType: "claude_local",
|
||||
cwd: "/tmp/workspace",
|
||||
command: "claude",
|
||||
commandArgs: ["--dangerously-skip-permissions"],
|
||||
commandNotes: ["Prompt is piped to claude via stdin."],
|
||||
prompt: "very long prompt body",
|
||||
context: { triggeredBy: "board" },
|
||||
env: { ANTHROPIC_API_KEY: "***REDACTED***" },
|
||||
}}
|
||||
censorUsernameInLogs={false}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Invocation");
|
||||
expect(html).toContain("Adapter:");
|
||||
expect(html).toContain("Working dir:");
|
||||
expect(html).toContain("Details");
|
||||
expect(html).not.toContain("Command:");
|
||||
expect(html).not.toContain("Prompt is piped to claude via stdin.");
|
||||
expect(html).not.toContain("very long prompt body");
|
||||
expect(html).not.toContain("ANTHROPIC_API_KEY");
|
||||
expect(html).not.toContain("triggeredBy");
|
||||
});
|
||||
});
|
||||
|
|
@ -57,17 +57,9 @@ export const help: Record<string, string> = {
|
|||
budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.",
|
||||
};
|
||||
|
||||
export const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
gemini_local: "Gemini CLI (local)",
|
||||
opencode_local: "OpenCode (local)",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
cursor: "Cursor (local)",
|
||||
hermes_local: "Hermes Agent",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
import { getAdapterLabels } from "../adapters/adapter-display-registry";
|
||||
|
||||
export const adapterLabels = getAdapterLabels();
|
||||
|
||||
export const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
|
||||
|
|
|
|||
|
|
@ -81,4 +81,33 @@ describe("RunTranscriptView", () => {
|
|||
text: "Working on the task.",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders successful result summaries as markdown in nice mode", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<RunTranscriptView
|
||||
density="compact"
|
||||
entries={[
|
||||
{
|
||||
kind: "result",
|
||||
ts: "2026-03-12T00:00:02.000Z",
|
||||
text: "## Summary\n\n- fixed deploy config\n- posted issue update",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0,
|
||||
subtype: "success",
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("<h2>Summary</h2>");
|
||||
expect(html).toContain("<li>fixed deploy config</li>");
|
||||
expect(html).toContain("<li>posted issue update</li>");
|
||||
expect(html).not.toContain("result");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
GitCompare,
|
||||
TerminalSquare,
|
||||
User,
|
||||
Wrench,
|
||||
|
|
@ -92,6 +93,12 @@ type TranscriptBlock =
|
|||
endTs?: string;
|
||||
lines: Array<{ ts: string; text: string }>;
|
||||
}
|
||||
| {
|
||||
type: "system_group";
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
lines: Array<{ ts: string; text: string }>;
|
||||
}
|
||||
| {
|
||||
type: "stdout";
|
||||
ts: string;
|
||||
|
|
@ -104,6 +111,16 @@ type TranscriptBlock =
|
|||
tone: "info" | "warn" | "error" | "neutral";
|
||||
text: string;
|
||||
detail?: string;
|
||||
}
|
||||
| {
|
||||
type: "diff_group";
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
filePath?: string;
|
||||
hunks: Array<{
|
||||
changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
|
|
@ -491,6 +508,10 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
label: "result",
|
||||
tone: entry.isError ? "error" : "info",
|
||||
text: entry.text.trim() || entry.errors[0] || (entry.isError ? "Run failed" : "Completed"),
|
||||
detail:
|
||||
!entry.isError && entry.text.trim().length > 0
|
||||
? `${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`
|
||||
: undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -543,13 +564,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
}
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: "event",
|
||||
ts: entry.ts,
|
||||
label: "system",
|
||||
tone: "warn",
|
||||
text: entry.text,
|
||||
});
|
||||
// Batch consecutive system events into a single collapsible group
|
||||
const prev = blocks[blocks.length - 1];
|
||||
if (prev && prev.type === "system_group") {
|
||||
prev.lines.push({ ts: entry.ts, text: entry.text });
|
||||
prev.endTs = entry.ts;
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "system_group",
|
||||
ts: entry.ts,
|
||||
endTs: entry.ts,
|
||||
lines: [{ ts: entry.ts, text: entry.text }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -564,6 +591,28 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
continue;
|
||||
}
|
||||
|
||||
// ── Diff entries — accumulate into diff_group blocks ──────────
|
||||
if (entry.kind === "diff") {
|
||||
const prev = blocks[blocks.length - 1];
|
||||
if (prev && prev.type === "diff_group") {
|
||||
if (entry.changeType === "file_header") {
|
||||
// New file in the same diff block — update filePath
|
||||
prev.filePath = entry.text;
|
||||
}
|
||||
prev.hunks.push({ changeType: entry.changeType, text: entry.text });
|
||||
prev.endTs = entry.ts;
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "diff_group",
|
||||
ts: entry.ts,
|
||||
endTs: entry.ts,
|
||||
filePath: entry.changeType === "file_header" ? entry.text : undefined,
|
||||
hunks: [{ changeType: entry.changeType, text: entry.text }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous?.type === "stdout") {
|
||||
previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`;
|
||||
previous.ts = entry.ts;
|
||||
|
|
@ -1062,9 +1111,14 @@ function TranscriptEventRow({
|
|||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{block.label === "result" && block.tone !== "error" ? (
|
||||
<div className={cn("whitespace-pre-wrap break-words text-sky-700 dark:text-sky-300", compact ? "text-[11px]" : "text-xs")}>
|
||||
<MarkdownBody
|
||||
className={cn(
|
||||
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 text-sky-700 dark:text-sky-300",
|
||||
compact ? "text-[11px] leading-5" : "text-xs leading-5",
|
||||
)}
|
||||
>
|
||||
{block.text}
|
||||
</div>
|
||||
</MarkdownBody>
|
||||
) : (
|
||||
<div className={cn("whitespace-pre-wrap break-words", compact ? "text-[11px]" : "text-xs")}>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/70">
|
||||
|
|
@ -1084,6 +1138,103 @@ function TranscriptEventRow({
|
|||
);
|
||||
}
|
||||
|
||||
function TranscriptDiffGroup({
|
||||
block,
|
||||
density,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "diff_group" }>;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
|
||||
// Count add/remove lines (exclude context, hunk, file_header, truncation)
|
||||
const addCount = block.hunks.filter((h) => h.changeType === "add").length;
|
||||
const removeCount = block.hunks.filter((h) => h.changeType === "remove").length;
|
||||
const hasChanges = addCount > 0 || removeCount > 0;
|
||||
|
||||
// Extract a short file name from the path
|
||||
const shortFile = block.filePath
|
||||
? block.filePath.split("/").pop() ?? block.filePath
|
||||
: "diff";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/[0.04] p-2">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
|
||||
>
|
||||
<GitCompare className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
|
||||
<span className={cn("text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-700 dark:text-blue-300")}>
|
||||
{shortFile}
|
||||
</span>
|
||||
{hasChanges && (
|
||||
<span className="text-[10px] tabular-nums">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">+{addCount}</span>
|
||||
{" "}
|
||||
<span className="text-red-600 dark:text-red-400">-{removeCount}</span>
|
||||
</span>
|
||||
)}
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
{open && (
|
||||
<pre className={cn(
|
||||
"mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono pl-5",
|
||||
compact ? "text-[11px]" : "text-xs",
|
||||
)}>
|
||||
{block.hunks.map((hunk, i) => {
|
||||
const key = `${i}-${hunk.changeType}`;
|
||||
switch (hunk.changeType) {
|
||||
case "remove":
|
||||
return (
|
||||
<span key={key} className="block bg-red-500/[0.10] text-red-700 dark:text-red-300 -mx-2 px-2">
|
||||
<span className="select-none mr-2 text-red-500/60 dark:text-red-400/50">-</span>
|
||||
{hunk.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
case "add":
|
||||
return (
|
||||
<span key={key} className="block bg-emerald-500/[0.10] text-emerald-700 dark:text-emerald-300 -mx-2 px-2">
|
||||
<span className="select-none mr-2 text-emerald-500/60 dark:text-emerald-400/50">+</span>
|
||||
{hunk.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
case "file_header":
|
||||
return (
|
||||
<span key={key} className="block font-semibold text-blue-600 dark:text-blue-300 mt-2 first:mt-0">
|
||||
{hunk.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
case "truncation":
|
||||
return (
|
||||
<span key={key} className="block text-muted-foreground italic mt-1">
|
||||
{hunk.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
case "context":
|
||||
default:
|
||||
return (
|
||||
<span key={key} className="block text-muted-foreground/70">
|
||||
{" "}
|
||||
{hunk.text}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptStderrGroup({
|
||||
block,
|
||||
density,
|
||||
|
|
@ -1121,6 +1272,43 @@ function TranscriptStderrGroup({
|
|||
);
|
||||
}
|
||||
|
||||
function TranscriptSystemGroup({
|
||||
block,
|
||||
density,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "system_group" }>;
|
||||
density: TranscriptDensity;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="rounded-xl border border-blue-500/20 bg-blue-500/[0.04] p-2 text-blue-700 dark:text-blue-300">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
|
||||
>
|
||||
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.14em]">
|
||||
{block.lines.length} system {block.lines.length === 1 ? "message" : "messages"}
|
||||
</span>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</div>
|
||||
{open && (
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-blue-700/80 dark:text-blue-300/80 pl-5">
|
||||
{block.lines.map((line, i) => (
|
||||
<span key={`${line.ts}-${i}`}>
|
||||
<span className="select-none text-blue-500/40 dark:text-blue-400/30">{i > 0 ? "\n" : ""}</span>
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptStdoutRow({
|
||||
block,
|
||||
density,
|
||||
|
|
@ -1242,7 +1430,9 @@ export function RunTranscriptView({
|
|||
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
||||
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
|
||||
{block.type === "diff_group" && <TranscriptDiffGroup block={block} density={density} />}
|
||||
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
|
||||
{block.type === "system_group" && <TranscriptSystemGroup block={block} density={density} />}
|
||||
{block.type === "stdout" && (
|
||||
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import type { LiveEvent } from "@paperclipai/shared";
|
||||
import { instanceSettingsApi } from "../../api/instanceSettings";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats";
|
||||
import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters";
|
||||
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
|
||||
const LOG_POLL_INTERVAL_MS = 2000;
|
||||
|
|
@ -68,6 +68,11 @@ export function useLiveRunTranscripts({
|
|||
const seenChunkKeysRef = useRef(new Set<string>());
|
||||
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
|
||||
const logOffsetByRunRef = useRef(new Map<string, number>());
|
||||
// Tick counter to force transcript recomputation when dynamic parser loads
|
||||
const [parserTick, setParserTick] = useState(0);
|
||||
useEffect(() => {
|
||||
return onAdapterChange(() => setParserTick((t) => t + 1));
|
||||
}, []);
|
||||
const { data: generalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.generalSettings,
|
||||
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||
|
|
@ -279,13 +284,13 @@ export function useLiveRunTranscripts({
|
|||
const adapter = getUIAdapter(run.adapterType);
|
||||
next.set(
|
||||
run.id,
|
||||
buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine, {
|
||||
buildTranscript(chunksByRun.get(run.id) ?? [], adapter, {
|
||||
censorUsernameInLogs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}, [chunksByRun, generalSettings?.censorUsernameInLogs, runs]);
|
||||
}, [chunksByRun, generalSettings?.censorUsernameInLogs, parserTick, runs]);
|
||||
|
||||
return {
|
||||
transcriptByRun,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue