mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Optional product features need small, typed model decisions. > - Each company needs to choose which shared API connection pays for those decisions. > - Calls must retain user and task permissions, budget limits, and cost attribution. > - This pull request adds a managed decision service, setup UI, and request history. > - Features can check availability cheaply and keep their existing behavior when decisions are unavailable. ## Linked Issues or Issue Description **Subsystem affected** Server services, shared contracts, database accounting, Company Settings, and Costs. **Problem or motivation** Paperclip has no common decision-model service. Adding provider calls within each feature would duplicate credential access, permission checks, and billing rules. **Proposed solution** Let a connection manager configure one company decision model. Support OpenAI Decisions and Jev through OpenRouter. Provide a fixed setup test and metadata-only history. Default company-sponsored background decisions to on during setup, and preserve a saved off setting. **Alternatives considered** Per-feature credentials would duplicate existing connection management. Personal overrides and provider fallback chains add permission and billing complexity; they remain deferred. **Roadmap alignment** Reviewed ROADMAP.md and searched open PRs. This extends existing connection access and budget accounting. Product features that call the service remain outside this change. No matching decision-model service PR was found. ## What Changed - Add company settings, an internal `decisionModelService`, local availability checks, and trusted human, agent/run, and system contexts. - Pin Vercel AI SDK provider dependencies and adapt boolean, choice, and ordered-score decisions for both providers. Bound requests and time; disable paid retries. - Add durable invocation metadata and agentless decision ledger charges. Preserve fractional cents, pricing evidence, dispatch identity, and unresolved billing holds. - Share the company accounting lock and apply company, agent, and project budgets. Settle charges once, retain unknown holds, and recover interrupted calls without resubmission. - Reuse connection setup and management UI. Add a Decisions view under Costs, production-component Storybook coverage, database migration, and service documentation. ## Verification - Passed 166 current-code tests covering the decision service/provider, setup component, Costs, OpenAPI, and every failure from the earlier broad run. Coverage includes native SDK wire formats, refusals, billed malformed responses, permission and secret-rotation races, identity changes, concurrent budget admission, unresolved holds, agent/task deletion, and stale setup feedback. - Passed 170 existing connection, cost, budget, heartbeat-accounting, and profile regression tests. - Passed repository typecheck, production build, and design token gates after integrating master. Verified the generated migration on a fresh test database and upgraded the populated preview database from the branch's earlier migration without losing settings or usage. - Ran the required full `pnpm test:run`: its general phase completed with 16,354 passed and 10 failures across five files while this branch was still being updated. Every reported failure passes in the current-code rerun; the serialized phase did not run after that failure. The full GitHub CI suite passed on `b1b856a88`: general and serialized tests, browser shards, runner checks, typecheck, production build, packaging/canary, and policy gates. [CI evidence](https://github.com/paperclipai/paperclip/actions/runs/37673106360). - Passed the full-shell Storybook setup-to-history interaction test again after integrating master. Greptile rates the final revision 5/5 with zero unresolved threads. - Walked through the running app: empty setup, add each provider, save, reload, run all three sample questions, inspect fractional charges in history, switch provider while sponsorship is off, disable, and reconnect. Checked mobile settings. These tests used the actual UI, vault, server, SDKs, and database with simulated upstream responses. - Live paid setup tests remain unverified: this environment has no authorized OpenAI/OpenRouter credentials available. No mocked test is presented as live provider evidence. Reviewer journey: Company Settings → General → Decision model. Add/select a shared API connection, save, run the billed sample, open View usage, then disable decisions and verify Run test is disabled after reload. ## Risks - The SDK decision interface is experimental. Pinned versions and wire-format tests limit upgrade drift. - The migration allows agentless service charges and reservations. Existing agent cost-reporting APIs still require an agent, and decision receipts stay separate from run reconciliation. - Timeouts can have unknown provider charges. Holds remain until an audited accounting correction resolves them. - OpenAI prices use a versioned Decisions rate snapshot; OpenRouter costs use provider receipts. Unknown pricing is retained as unknown. - A configured company authorizes background spending by default. Setup explains this, and managers can turn it off. - Live provider account/model availability still needs the two credentialed acceptance checks. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository inspection, code execution, and browser tools. The exact serving revision and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
463 lines
16 KiB
TypeScript
463 lines
16 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import {
|
|
Tooltip,
|
|
TooltipTrigger,
|
|
TooltipContent,
|
|
} from "@/components/ui/tooltip";
|
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { HelpCircle, ChevronDown, ChevronRight } from "lucide-react";
|
|
import { cn } from "../lib/utils";
|
|
import { AGENT_ROLE_LABELS } from "@paperclipai/shared";
|
|
|
|
/* ---- Help text for (?) tooltips ---- */
|
|
export const help: Record<string, string> = {
|
|
name: "Display name for this agent.",
|
|
title: "Job title shown in the org chart.",
|
|
role: "Organizational role. Determines position and capabilities.",
|
|
reportsTo: "The agent this one reports to in the org hierarchy.",
|
|
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
|
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
|
|
cwd: "Deprecated legacy working directory fallback for local adapters. Existing agents may still carry this value, but new configurations should use project workspaces instead.",
|
|
promptTemplate: "Sent on every heartbeat. Keep this small and dynamic. Use it for current-task framing, not large static instructions. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} and other template variables.",
|
|
model: "Override the default model used by the adapter.",
|
|
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
|
|
chrome: "Enable Claude's Chrome integration by passing --chrome.",
|
|
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
|
|
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
|
search: "Enable Codex web search capability during runs.",
|
|
fastMode: "Enable Codex Fast mode. This burns credits/tokens much faster and is supported on GPT-6, GPT-5.6, GPT-5.5, GPT-5.4, and manual Codex model IDs.",
|
|
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
|
|
workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.",
|
|
workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.",
|
|
worktreeParentDir: "Directory where derived worktrees should be created. Absolute, ~-prefixed, and repo-relative paths are supported.",
|
|
runtimeServicesJson: "Optional workspace runtime service definitions. Use this for shared app servers, workers, or other long-lived companion processes attached to the workspace.",
|
|
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
|
command: "The command to execute (e.g. node, python).",
|
|
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
|
|
args: "Command-line arguments, comma-separated.",
|
|
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
|
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
|
secretAccess:
|
|
"Secrets this agent can reach. Env-var bindings are injected at run start; API-access bindings are fetched on demand via the run-bound agent API and never written to the environment.",
|
|
bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.",
|
|
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
|
|
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
|
|
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
|
|
intervalSec: "Seconds between automatic heartbeat invocations.",
|
|
timeoutSec: "Maximum seconds a run can take before being terminated. 0 means no timeout.",
|
|
graceSec: "Seconds to wait after sending interrupt before force-killing the process.",
|
|
wakeOnDemand: "Allow this agent to be woken by assignments, API calls, UI actions, or automated systems.",
|
|
cooldownSec: "Minimum seconds between consecutive heartbeat runs.",
|
|
maxConcurrentRuns: "Maximum number of heartbeat runs that can execute simultaneously for this agent.",
|
|
maxTurnContinuationEnabled: "Automatically queue bounded continuation runs when an adapter stops because its per-run turn cap was exhausted.",
|
|
maxTurnContinuationMaxAttempts: "Maximum automatic continuations after one max-turn stop. This is separate from max turns per run.",
|
|
maxTurnContinuationDelaySec: "Seconds to wait before starting each max-turn continuation.",
|
|
budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.",
|
|
};
|
|
|
|
import { getAdapterLabels } from "../adapters/adapter-display-registry";
|
|
|
|
export const adapterLabels = getAdapterLabels();
|
|
|
|
export const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
|
|
|
/* ---- Primitive components ---- */
|
|
|
|
export function HintIcon({ text }: { text: string }) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button type="button" className="inline-flex text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
|
<HelpCircle className="h-3 w-3" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-xs">
|
|
{text}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode; configSection?: import("../adapters/types").AdapterConfigSection }) {
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-1.5 mb-1">
|
|
<label className="text-xs text-muted-foreground">{label}</label>
|
|
{hint && <HintIcon text={hint} />}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ToggleField({
|
|
label,
|
|
hint,
|
|
checked,
|
|
onChange,
|
|
toggleTestId,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
toggleTestId?: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-muted-foreground">{label}</span>
|
|
{hint && <HintIcon text={hint} />}
|
|
</div>
|
|
{/* Gallery feedback r3: was a hand-rolled h-5 w-9 pill with a bg-green-600
|
|
track — the app's second switch implementation. Converged on the one
|
|
canonical ToggleSwitch (status-green on-state), DESIGN.md principle 1. */}
|
|
<ToggleSwitch
|
|
data-testid={toggleTestId}
|
|
aria-label={label}
|
|
checked={checked}
|
|
onCheckedChange={onChange}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ToggleWithNumber({
|
|
label,
|
|
hint,
|
|
checked,
|
|
onCheckedChange,
|
|
number,
|
|
onNumberChange,
|
|
numberLabel,
|
|
numberHint,
|
|
numberPrefix,
|
|
showNumber,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
checked: boolean;
|
|
onCheckedChange: (v: boolean) => void;
|
|
number: number;
|
|
onNumberChange: (v: number) => void;
|
|
numberLabel: string;
|
|
numberHint?: string;
|
|
numberPrefix?: string;
|
|
showNumber: boolean;
|
|
}) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-muted-foreground">{label}</span>
|
|
{hint && <HintIcon text={hint} />}
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={checked}
|
|
onCheckedChange={onCheckedChange}
|
|
/>
|
|
</div>
|
|
{showNumber && (
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
{numberPrefix && <span>{numberPrefix}</span>}
|
|
<input
|
|
type="number"
|
|
className="w-16 rounded-md border border-border px-2 py-0.5 bg-transparent outline-none text-xs font-mono text-center"
|
|
value={number}
|
|
onChange={(e) => onNumberChange(Number(e.target.value))}
|
|
/>
|
|
<span>{numberLabel}</span>
|
|
{numberHint && <HintIcon text={numberHint} />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CollapsibleSection({
|
|
title,
|
|
icon,
|
|
open,
|
|
onToggle,
|
|
bordered,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
icon?: React.ReactNode;
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
bordered?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={cn(bordered && "border-t border-border")}>
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
className="flex items-center gap-2 w-full px-4 py-2 text-xs font-medium text-muted-foreground hover:bg-accent/30 transition-colors"
|
|
onClick={onToggle}
|
|
>
|
|
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
{icon}
|
|
{title}
|
|
</button>
|
|
{open && <div className="px-4 pb-3">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AutoExpandTextarea({
|
|
value,
|
|
onChange,
|
|
onBlur,
|
|
placeholder,
|
|
minRows,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
onBlur?: () => void;
|
|
placeholder?: string;
|
|
minRows?: number;
|
|
}) {
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const rows = minRows ?? 3;
|
|
const lineHeight = 20;
|
|
const minHeight = rows * lineHeight;
|
|
|
|
const adjustHeight = useCallback(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
|
|
}, [minHeight]);
|
|
|
|
useEffect(() => { adjustHeight(); }, [value, adjustHeight]);
|
|
|
|
return (
|
|
<textarea
|
|
ref={textareaRef}
|
|
className="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 resize-none overflow-hidden"
|
|
placeholder={placeholder}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onBlur={onBlur}
|
|
style={{ minHeight }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Text input that manages internal draft state.
|
|
* Calls `onCommit` on blur (and optionally on every change if `immediate` is set).
|
|
*/
|
|
export function DraftInput({
|
|
value,
|
|
onCommit,
|
|
immediate,
|
|
className,
|
|
...props
|
|
}: {
|
|
value: string;
|
|
onCommit: (v: string) => void;
|
|
immediate?: boolean;
|
|
className?: string;
|
|
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "className">) {
|
|
const [draft, setDraft] = useState(value);
|
|
useEffect(() => setDraft(value), [value]);
|
|
|
|
return (
|
|
<input
|
|
className={className}
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
if (immediate) onCommit(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
if (draft !== value) onCommit(draft);
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Auto-expanding textarea with draft state and blur-commit.
|
|
*/
|
|
export function DraftTextarea({
|
|
value,
|
|
onCommit,
|
|
immediate,
|
|
placeholder,
|
|
minRows,
|
|
}: {
|
|
value: string;
|
|
onCommit: (v: string) => void;
|
|
immediate?: boolean;
|
|
placeholder?: string;
|
|
minRows?: number;
|
|
}) {
|
|
const [draft, setDraft] = useState(value);
|
|
useEffect(() => setDraft(value), [value]);
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const rows = minRows ?? 3;
|
|
const lineHeight = 20;
|
|
const minHeight = rows * lineHeight;
|
|
|
|
const adjustHeight = useCallback(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
|
|
}, [minHeight]);
|
|
|
|
useEffect(() => { adjustHeight(); }, [draft, adjustHeight]);
|
|
|
|
return (
|
|
<textarea
|
|
ref={textareaRef}
|
|
className="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 resize-none overflow-hidden"
|
|
placeholder={placeholder}
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
if (immediate) onCommit(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
if (draft !== value) onCommit(draft);
|
|
}}
|
|
style={{ minHeight }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Number input with draft state and blur-commit.
|
|
*/
|
|
export function DraftNumberInput({
|
|
value,
|
|
onCommit,
|
|
immediate,
|
|
className,
|
|
...props
|
|
}: {
|
|
value: number;
|
|
onCommit: (v: number) => void;
|
|
immediate?: boolean;
|
|
className?: string;
|
|
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "className" | "type">) {
|
|
const [draft, setDraft] = useState(String(value));
|
|
useEffect(() => setDraft(String(value)), [value]);
|
|
|
|
return (
|
|
<input
|
|
type="number"
|
|
className={className}
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
if (immediate) onCommit(Number(e.target.value) || 0);
|
|
}}
|
|
onBlur={() => {
|
|
const num = Number(draft) || 0;
|
|
if (num !== value) onCommit(num);
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* "Choose" button that opens a dialog explaining the user must manually
|
|
* type the path due to browser security limitations.
|
|
*/
|
|
export function ChoosePathButton() {
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
Choose
|
|
</button>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Specify path manually</DialogTitle>
|
|
<DialogDescription>
|
|
Browser security blocks apps from reading full local paths via a file picker.
|
|
Copy the absolute path and paste it into the input.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 text-sm">
|
|
<section className="space-y-1.5">
|
|
<p className="font-medium">macOS (Finder)</p>
|
|
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
|
<li>Find the folder in Finder.</li>
|
|
<li>Hold <kbd>Option</kbd> and right-click the folder.</li>
|
|
<li>Click "Copy <folder name> as Pathname".</li>
|
|
<li>Paste the result into the path input.</li>
|
|
</ol>
|
|
<p className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
|
|
/Users/yourname/Documents/project
|
|
</p>
|
|
</section>
|
|
<section className="space-y-1.5">
|
|
<p className="font-medium">Windows (File Explorer)</p>
|
|
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
|
<li>Find the folder in File Explorer.</li>
|
|
<li>Hold <kbd>Shift</kbd> and right-click the folder.</li>
|
|
<li>Click "Copy as path".</li>
|
|
<li>Paste the result into the path input.</li>
|
|
</ol>
|
|
<p className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
|
|
C:\Users\yourname\Documents\project
|
|
</p>
|
|
</section>
|
|
<section className="space-y-1.5">
|
|
<p className="font-medium">Terminal fallback (macOS/Linux)</p>
|
|
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
|
<li>Run <code>cd /path/to/folder</code>.</li>
|
|
<li>Run <code>pwd</code>.</li>
|
|
<li>Copy the output and paste it into the path input.</li>
|
|
</ol>
|
|
</section>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
OK
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Label + input rendered on the same line (inline layout for compact fields).
|
|
*/
|
|
export function InlineField({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<label className="text-xs text-muted-foreground">{label}</label>
|
|
{hint && <HintIcon text={hint} />}
|
|
</div>
|
|
<div className="w-24 ml-auto">{children}</div>
|
|
</div>
|
|
);
|
|
}
|