UI: secrets-aware env editor, issue hiding, and adapter env bindings
Replace plain text env var editor with structured EnvVarEditor supporting plain values and secret references with inline "Seal" to convert a value to a managed secret. Add issue hide action via popover menu on issue detail. Adapters now emit envBindings with typed plain/secret_ref entries instead of raw KEY=VALUE strings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,34 @@ function parseEnvVars(text: string): Record<string, string> {
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
@@ -30,7 +58,13 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
if (v.model) ac.model = v.model;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvVars(v.envVars);
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
|
||||
@@ -22,6 +22,34 @@ function parseEnvVars(text: string): Record<string, string> {
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
@@ -30,7 +58,13 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
||||
if (v.model) ac.model = v.model;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvVars(v.envVars);
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.search = v.search;
|
||||
ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox;
|
||||
|
||||
25
ui/src/api/secrets.ts
Normal file
25
ui/src/api/secrets.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { CompanySecret, SecretProviderDescriptor, SecretProvider } from "@paperclip/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const secretsApi = {
|
||||
list: (companyId: string) => api.get<CompanySecret[]>(`/companies/${companyId}/secrets`),
|
||||
providers: (companyId: string) =>
|
||||
api.get<SecretProviderDescriptor[]>(`/companies/${companyId}/secret-providers`),
|
||||
create: (
|
||||
companyId: string,
|
||||
data: {
|
||||
name: string;
|
||||
value: string;
|
||||
provider?: SecretProvider;
|
||||
description?: string | null;
|
||||
externalRef?: string | null;
|
||||
},
|
||||
) => api.post<CompanySecret>(`/companies/${companyId}/secrets`, data),
|
||||
rotate: (id: string, data: { value: string; externalRef?: string | null }) =>
|
||||
api.post<CompanySecret>(`/secrets/${id}/rotate`, data),
|
||||
update: (
|
||||
id: string,
|
||||
data: { name?: string; description?: string | null; externalRef?: string | null },
|
||||
) => api.patch<CompanySecret>(`/secrets/${id}`, data),
|
||||
remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`),
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AGENT_ADAPTER_TYPES } from "@paperclip/shared";
|
||||
import type { Agent } from "@paperclip/shared";
|
||||
import type { Agent, CompanySecret, EnvBinding } from "@paperclip/shared";
|
||||
import type { AdapterModel } from "../api/agents";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { secretsApi } from "../api/secrets";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import {
|
||||
Field,
|
||||
ToggleField,
|
||||
@@ -46,6 +49,7 @@ export const defaultCreateValues: CreateConfigValues = {
|
||||
args: "",
|
||||
extraArgs: "",
|
||||
envVars: "",
|
||||
envBindings: {},
|
||||
url: "",
|
||||
bootstrapPrompt: "",
|
||||
maxTurnsPerRun: 80,
|
||||
@@ -134,6 +138,25 @@ function extractPickedDirectoryPath(handle: unknown): string | null {
|
||||
export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const { mode, adapterModels: externalModels } = props;
|
||||
const isCreate = mode === "create";
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: availableSecrets = [] } = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
|
||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
const createSecret = useMutation({
|
||||
mutationFn: (input: { name: string; value: string }) => {
|
||||
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
|
||||
return secretsApi.create(selectedCompanyId, input);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (!selectedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Edit mode: overlay for dirty tracking ----
|
||||
const [overlay, setOverlay] = useState<Overlay>(emptyOverlay);
|
||||
@@ -510,19 +533,24 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
</Field>
|
||||
|
||||
<Field label="Environment variables" hint={help.envVars}>
|
||||
{isCreate ? (
|
||||
<AutoExpandTextarea
|
||||
placeholder={"ANTHROPIC_API_KEY=...\nPAPERCLIP_API_URL=http://localhost:3100"}
|
||||
value={val!.envVars}
|
||||
onChange={(v) => set!({ envVars: v })}
|
||||
minRows={3}
|
||||
/>
|
||||
) : (
|
||||
<EnvVarEditor
|
||||
value={(eff("adapterConfig", "env", config.env ?? {}) as Record<string, string>)}
|
||||
onChange={(env) => mark("adapterConfig", "env", env)}
|
||||
/>
|
||||
)}
|
||||
<EnvVarEditor
|
||||
value={
|
||||
isCreate
|
||||
? ((val!.envBindings ?? {}) as Record<string, EnvBinding>)
|
||||
: ((eff("adapterConfig", "env", config.env ?? {}) as Record<string, EnvBinding>)
|
||||
)
|
||||
}
|
||||
secrets={availableSecrets}
|
||||
onCreateSecret={async (name, value) => {
|
||||
const created = await createSecret.mutateAsync({ name, value });
|
||||
return created;
|
||||
}}
|
||||
onChange={(env) =>
|
||||
isCreate
|
||||
? set!({ envBindings: env ?? {}, envVars: "" })
|
||||
: mark("adapterConfig", "env", env)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Edit-only: timeout + grace period */}
|
||||
@@ -697,20 +725,75 @@ function AdapterTypeDropdown({
|
||||
|
||||
function EnvVarEditor({
|
||||
value,
|
||||
secrets,
|
||||
onCreateSecret,
|
||||
onChange,
|
||||
}: {
|
||||
value: Record<string, string>;
|
||||
onChange: (env: Record<string, string> | undefined) => void;
|
||||
value: Record<string, EnvBinding>;
|
||||
secrets: CompanySecret[];
|
||||
onCreateSecret: (name: string, value: string) => Promise<CompanySecret>;
|
||||
onChange: (env: Record<string, EnvBinding> | undefined) => void;
|
||||
}) {
|
||||
type Row = { key: string; value: string };
|
||||
type Row = {
|
||||
key: string;
|
||||
source: "plain" | "secret";
|
||||
plainValue: string;
|
||||
secretId: string;
|
||||
};
|
||||
|
||||
function toRows(rec: Record<string, string> | null | undefined): Row[] {
|
||||
if (!rec || typeof rec !== "object") return [{ key: "", value: "" }];
|
||||
const entries = Object.entries(rec).map(([k, v]) => ({ key: k, value: String(v) }));
|
||||
return [...entries, { key: "", value: "" }];
|
||||
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
|
||||
if (!rec || typeof rec !== "object") {
|
||||
return [{ key: "", source: "plain", plainValue: "", secretId: "" }];
|
||||
}
|
||||
const entries = Object.entries(rec).map(([k, binding]) => {
|
||||
if (typeof binding === "string") {
|
||||
return {
|
||||
key: k,
|
||||
source: "plain" as const,
|
||||
plainValue: binding,
|
||||
secretId: "",
|
||||
};
|
||||
}
|
||||
if (
|
||||
typeof binding === "object" &&
|
||||
binding !== null &&
|
||||
"type" in binding &&
|
||||
(binding as { type?: unknown }).type === "secret_ref"
|
||||
) {
|
||||
const recBinding = binding as { secretId?: unknown };
|
||||
return {
|
||||
key: k,
|
||||
source: "secret" as const,
|
||||
plainValue: "",
|
||||
secretId: typeof recBinding.secretId === "string" ? recBinding.secretId : "",
|
||||
};
|
||||
}
|
||||
if (
|
||||
typeof binding === "object" &&
|
||||
binding !== null &&
|
||||
"type" in binding &&
|
||||
(binding as { type?: unknown }).type === "plain"
|
||||
) {
|
||||
const recBinding = binding as { value?: unknown };
|
||||
return {
|
||||
key: k,
|
||||
source: "plain" as const,
|
||||
plainValue: typeof recBinding.value === "string" ? recBinding.value : "",
|
||||
secretId: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: k,
|
||||
source: "plain" as const,
|
||||
plainValue: "",
|
||||
secretId: "",
|
||||
};
|
||||
});
|
||||
return [...entries, { key: "", source: "plain", plainValue: "", secretId: "" }];
|
||||
}
|
||||
|
||||
const [rows, setRows] = useState<Row[]>(() => toRows(value));
|
||||
const [sealError, setSealError] = useState<string | null>(null);
|
||||
const valueRef = useRef(value);
|
||||
|
||||
// Sync when value identity changes (overlay reset after save)
|
||||
@@ -722,50 +805,151 @@ function EnvVarEditor({
|
||||
}, [value]);
|
||||
|
||||
function emit(nextRows: Row[]) {
|
||||
const rec: Record<string, string> = {};
|
||||
const rec: Record<string, EnvBinding> = {};
|
||||
for (const row of nextRows) {
|
||||
const k = row.key.trim();
|
||||
if (k) rec[k] = row.value;
|
||||
if (!k) continue;
|
||||
if (row.source === "secret") {
|
||||
if (!row.secretId) continue;
|
||||
rec[k] = { type: "secret_ref", secretId: row.secretId, version: "latest" };
|
||||
} else {
|
||||
rec[k] = { type: "plain", value: row.plainValue };
|
||||
}
|
||||
}
|
||||
onChange(Object.keys(rec).length > 0 ? rec : undefined);
|
||||
}
|
||||
|
||||
function updateRow(i: number, field: "key" | "value", v: string) {
|
||||
const next = rows.map((r, idx) => (idx === i ? { ...r, [field]: v } : r));
|
||||
if (next[next.length - 1].key || next[next.length - 1].value) {
|
||||
next.push({ key: "", value: "" });
|
||||
function updateRow(i: number, patch: Partial<Row>) {
|
||||
const withPatch = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
|
||||
if (
|
||||
withPatch[withPatch.length - 1].key ||
|
||||
withPatch[withPatch.length - 1].plainValue ||
|
||||
withPatch[withPatch.length - 1].secretId
|
||||
) {
|
||||
withPatch.push({ key: "", source: "plain", plainValue: "", secretId: "" });
|
||||
}
|
||||
setRows(withPatch);
|
||||
emit(withPatch);
|
||||
}
|
||||
|
||||
function removeRow(i: number) {
|
||||
const next = rows.filter((_, idx) => idx !== i);
|
||||
if (
|
||||
next.length === 0 ||
|
||||
next[next.length - 1].key ||
|
||||
next[next.length - 1].plainValue ||
|
||||
next[next.length - 1].secretId
|
||||
) {
|
||||
next.push({ key: "", source: "plain", plainValue: "", secretId: "" });
|
||||
}
|
||||
setRows(next);
|
||||
emit(next);
|
||||
}
|
||||
|
||||
function removeRow(i: number) {
|
||||
const next = rows.filter((_, idx) => idx !== i);
|
||||
if (next.length === 0 || next[next.length - 1].key || next[next.length - 1].value) {
|
||||
next.push({ key: "", value: "" });
|
||||
function defaultSecretName(key: string): string {
|
||||
return key
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
async function sealRow(i: number) {
|
||||
const row = rows[i];
|
||||
if (!row) return;
|
||||
const key = row.key.trim();
|
||||
const plain = row.plainValue;
|
||||
if (!key || plain.length === 0) return;
|
||||
|
||||
const suggested = defaultSecretName(key) || "secret";
|
||||
const name = window.prompt("Secret name", suggested)?.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
setSealError(null);
|
||||
const created = await onCreateSecret(name, plain);
|
||||
updateRow(i, {
|
||||
source: "secret",
|
||||
secretId: created.id,
|
||||
});
|
||||
} catch (err) {
|
||||
setSealError(err instanceof Error ? err.message : "Failed to create secret");
|
||||
}
|
||||
setRows(next);
|
||||
emit(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, i) => {
|
||||
const isTrailing = i === rows.length - 1 && !row.key && !row.value;
|
||||
const isTrailing =
|
||||
i === rows.length - 1 &&
|
||||
!row.key &&
|
||||
!row.plainValue &&
|
||||
!row.secretId;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className={cn(inputClass, "flex-[2]")}
|
||||
placeholder="KEY"
|
||||
value={row.key}
|
||||
onChange={(e) => updateRow(i, "key", e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={cn(inputClass, "flex-[3]")}
|
||||
placeholder="value"
|
||||
value={row.value}
|
||||
onChange={(e) => updateRow(i, "value", e.target.value)}
|
||||
onChange={(e) => updateRow(i, { key: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className={cn(inputClass, "flex-[1] bg-background")}
|
||||
value={row.source}
|
||||
onChange={(e) =>
|
||||
updateRow(i, {
|
||||
source: e.target.value === "secret" ? "secret" : "plain",
|
||||
...(e.target.value === "plain" ? { secretId: "" } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="plain">Plain</option>
|
||||
<option value="secret">Secret</option>
|
||||
</select>
|
||||
{row.source === "secret" ? (
|
||||
<>
|
||||
<select
|
||||
className={cn(inputClass, "flex-[3] bg-background")}
|
||||
value={row.secretId}
|
||||
onChange={(e) => updateRow(i, { secretId: e.target.value })}
|
||||
>
|
||||
<option value="">Select secret...</option>
|
||||
{secrets.map((secret) => (
|
||||
<option key={secret.id} value={secret.id}>
|
||||
{secret.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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={() => sealRow(i)}
|
||||
disabled={!row.key.trim() || !row.plainValue}
|
||||
title="Create secret from current plain value"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className={cn(inputClass, "flex-[3]")}
|
||||
placeholder="value"
|
||||
value={row.plainValue}
|
||||
onChange={(e) => updateRow(i, { plainValue: e.target.value })}
|
||||
/>
|
||||
<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={() => sealRow(i)}
|
||||
disabled={!row.key.trim() || !row.plainValue}
|
||||
title="Store value as secret and replace with reference"
|
||||
>
|
||||
Seal
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isTrailing ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -780,6 +964,7 @@ function EnvVarEditor({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sealError && <p className="text-[11px] text-destructive">{sealError}</p>}
|
||||
<p className="text-[11px] text-muted-foreground/60">
|
||||
PAPERCLIP_* variables are injected automatically at runtime.
|
||||
</p>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const help: Record<string, string> = {
|
||||
localCommand: "Override the local CLI command (e.g. claude, /usr/local/bin/claude, codex).",
|
||||
args: "Command-line arguments, comma-separated.",
|
||||
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
||||
envVars: "Environment variables injected into the adapter process. One KEY=VALUE per line.",
|
||||
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
||||
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.",
|
||||
|
||||
@@ -37,6 +37,10 @@ export const queryKeys = {
|
||||
comments: (approvalId: string) => ["approvals", "comments", approvalId] as const,
|
||||
issues: (approvalId: string) => ["approvals", "issues", approvalId] as const,
|
||||
},
|
||||
secrets: {
|
||||
list: (companyId: string) => ["secrets", companyId] as const,
|
||||
providers: (companyId: string) => ["secret-providers", companyId] as const,
|
||||
},
|
||||
dashboard: (companyId: string) => ["dashboard", companyId] as const,
|
||||
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
|
||||
activity: (companyId: string) => ["activity", companyId] as const,
|
||||
|
||||
@@ -72,6 +72,14 @@ function shouldRedactSecretValue(key: string, value: unknown): boolean {
|
||||
}
|
||||
|
||||
function redactEnvValue(key: string, value: unknown): string {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
(value as { type?: unknown }).type === "secret_ref"
|
||||
) {
|
||||
return "***SECRET_REF***";
|
||||
}
|
||||
if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { activityApi } from "../api/activity";
|
||||
@@ -18,7 +18,9 @@ import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronRight, MoreHorizontal, EyeOff } from "lucide-react";
|
||||
import type { ActivityEvent } from "@paperclip/shared";
|
||||
import type { Agent } from "@paperclip/shared";
|
||||
|
||||
@@ -94,6 +96,8 @@ export function IssueDetail() {
|
||||
const { openPanel, closePanel } = usePanel();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
const { data: issue, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(issueId!),
|
||||
@@ -242,6 +246,29 @@ export function IssueDetail() {
|
||||
onChange={(priority) => updateIssue.mutate({ priority })}
|
||||
/>
|
||||
<span className="text-xs font-mono text-muted-foreground">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="end">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
||||
onClick={() => {
|
||||
updateIssue.mutate(
|
||||
{ hiddenAt: new Date().toISOString() },
|
||||
{ onSuccess: () => navigate("/issues") },
|
||||
);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<EyeOff className="h-3 w-3" />
|
||||
Hide this Issue
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<InlineEditor
|
||||
|
||||
Reference in New Issue
Block a user