mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies. > - Agents already receive selected company secrets through `env.*` bindings at run launch, but environment injection is ambient, long-lived, and not suitable for every secret consumer. > - The existing binding and secret-access-event models already provide company-scoped authorization and per-resolution audit seams. > - Agents need an explicit way to discover only the secrets granted to them and fetch a value on demand without exposing the wider company catalog. > - That capability must remain run-bound, preserve low-trust token carve-outs, and make every value read visible in both security and operator audit trails. > - This pull request adds an `access.*` delivery namespace, two run-bound agent routes, dual audit logging, documentation, and an operator grants editor. > - The benefit is least-privilege, revocable, auditable secret access while preserving existing env injection behavior. ## Linked Issues or Issue Description No pre-existing public issue. Related work: - Refs #9797 — existing in-sheet agent access UI that this PR extends to distinguish env and API delivery. - Refs #9918 — complementary searchable-agent picker improvement for the same secrets sheet. - Refs #9530 — related company-wide metadata catalog proposal; this PR intentionally exposes only the authenticated run's granted aliases and values. **Problem / motivation:** Agents can currently consume secrets only through process environment injection. This keeps values resident for the run, does not support on-demand consumers, and cannot provide a discrete operator-visible activity event for each agent-initiated read. **Proposed solution:** Treat `company_secret_bindings` as the source of truth for agent secret grants. Keep `env.KEY` as env delivery and add `access.ALIAS` for API-only delivery; an env binding also implies read access because the value is already present in the agent process. Add run-bound list/fetch endpoints that derive scope from the authenticated heartbeat run and never accept caller-selected overlays. **Alternatives considered:** A company-wide agent-readable catalog was rejected for this value path because it increases reconnaissance and does not prove a per-secret grant. Reusing the ephemeral environment-probe resolver was rejected because it lacks binding enforcement. Approval-gated reads and user-scoped secrets remain deferred beyond v1. **Roadmap alignment:** This extends the completed **Secrets Manager with per-agent access** roadmap capability from launch-time env injection to explicit run-bound API delivery without duplicating a separate planned initiative. ## What Changed - Added `access.*` agent binding validation and a dedicated run-bound resolver that combines `secrets:read` authorization with binding-context enforcement. - Added `GET /api/agents/me/secrets` for minimal granted metadata and `POST /api/agents/me/secrets/:key/value` for on-demand value fetches with `Cache-Control: no-store`. - Preserved the existing denials for low-trust review agents, task-bridge credentials, and skill-test tokens; standard long-lived agent API keys cannot call the run-bound routes. - Added dual audit behavior: value attempts write `secret_access_events` and `activity_log` (`secret.value.read`), while metadata listing writes the lighter `secret.access.listed` activity event. - Kept env compatibility: `env.*` remains injected at launch and also implies API read for the same bound agent; `access.*` never becomes an environment variable. - Added the agent-settings **Secret access** editor plus delivery-mode/alias surfacing on the Secrets page, with focused UI tests and tokenized layout styles. - Updated OpenAPI, shared types, agent-facing skill documentation, and API reference documentation. ### UI Screenshots P3 produced and reviewed three screenshots using mock data; images are intentionally not committed to the repository: - `secret-access-editor.png` — agent settings grant editor. - `secret-access-light.png` — Secrets-page delivery surfacing in light mode. - `secret-access-dark.png` — Secrets-page delivery surfacing in dark mode. The source attachments are retained with the implementation task and linked in the internal handoff; the public page publisher was unavailable in the PR-prep runtime. ## Verification - `pnpm exec vitest run server/src/__tests__/agent-secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts server/src/__tests__/secrets-routes.test.ts ui/src/lib/secret-delivery.test.ts ui/src/components/AgentSecretAccessEditor.test.tsx` — 5 files, 122 tests passed. - Security follow-up: `pnpm exec vitest run server/src/__tests__/agent-secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts` — 2 files, 73 tests passed after active-run and version-consistency fixes. - Final-head CI: all feature, typecheck, build, e2e, security, and review gates pass; `General tests (server (1/3))` remains red after one rerun because unrelated `heartbeat-retry-scheduling.test.ts` cleanup deletes `heartbeat_runs` before referenced `activity_log` rows. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — feature-local arbitrary-value violations fixed; command still reports five unchanged `#9627` literals outside this PR. - End-to-end QA passed all eight acceptance criteria: grant/list, fetch, dual audit, env-implies-read, denial matrix, revocation, UI rendering, and env-injection regression. Evidence: https://github.com/paperclipai/paperclip/pull/9921#issuecomment-5027455492 - Security review returned PASS-with-required-changes; the implementation uses the required dedicated binding-enforcing resolver, run-bound JWT restriction, run-derived overlays, minimal metadata, and a resolver redaction-registration hook. Evidence: https://github.com/paperclipai/paperclip/pull/9921#issuecomment-5027455382 ## Risks - A compromised agent can exfiltrate any secret explicitly granted to it; explicit company-scoped/run-scoped grants, revocation, and audit reduce but cannot remove that inherent capability risk. - The resolver invokes a redaction-registration hook before returning values, but the current route has no persistent cross-request per-run redaction registry. Paperclip-owned later comments/events therefore cannot yet guarantee automatic scrubbing of a deliberately copied fetched value; QA classified this as non-blocking residual hardening. - Audit-event insertion currently fails open if the security-event insert itself fails; the operator activity event provides partial redundancy, but a future hardening change should define fail-closed behavior for value delivery. - This PR overlaps `ui/src/pages/Secrets.tsx` with #9918 and may require a straightforward rebase after that PR moves. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, `gpt-5.3-codex`, with reasoning, repository tool use, terminal execution, Paperclip API access, and GitHub CLI capabilities. Context-window size is not exposed by the runtime. - Anthropic Claude Opus 4.8 with 1M context and tool use assisted with the UI implementation commit. ## 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> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
460 lines
16 KiB
TypeScript
460 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-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 }) {
|
|
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}
|
|
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
|
|
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>
|
|
);
|
|
}
|