feat: per-issue assignee adapter overrides (model, effort, workspace)
Add assigneeAdapterOverrides JSONB column to issues, allowing per-issue model, thinking effort, and workspace overrides when assigning to agents. Heartbeat service merges overrides into adapter config at runtime. New Issue dialog exposes these options for Claude and Codex adapters. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
packages/db/src/migrations/0021_chief_vindicator.sql
Normal file
1
packages/db/src/migrations/0021_chief_vindicator.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "issues" ADD COLUMN "assignee_adapter_overrides" jsonb;
|
||||
5609
packages/db/src/migrations/meta/0021_snapshot.json
Normal file
5609
packages/db/src/migrations/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -148,6 +148,13 @@
|
||||
"when": 1772032176413,
|
||||
"tag": "0020_white_anita_blake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1772122471656,
|
||||
"tag": "0021_chief_vindicator",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
@@ -38,6 +39,7 @@ export const issues = pgTable(
|
||||
identifier: text("identifier"),
|
||||
requestDepth: integer("request_depth").notNull().default(0),
|
||||
billingCode: text("billing_code"),
|
||||
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
||||
|
||||
@@ -77,6 +77,7 @@ export type {
|
||||
ProjectGoalRef,
|
||||
ProjectWorkspace,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueComment,
|
||||
IssueAttachment,
|
||||
IssueLabel,
|
||||
|
||||
@@ -13,6 +13,7 @@ export type { AssetImage } from "./asset.js";
|
||||
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueComment,
|
||||
IssueAncestor,
|
||||
IssueAncestorProject,
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface IssueLabel {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface IssueAssigneeAdapterOverrides {
|
||||
adapterConfig?: Record<string, unknown>;
|
||||
useProjectWorkspace?: boolean;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -66,6 +71,7 @@ export interface Issue {
|
||||
identifier: string | null;
|
||||
requestDepth: number;
|
||||
billingCode: string | null;
|
||||
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
|
||||
|
||||
export const issueAssigneeAdapterOverridesSchema = z
|
||||
.object({
|
||||
adapterConfig: z.record(z.unknown()).optional(),
|
||||
useProjectWorkspace: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const createIssueSchema = z.object({
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
goalId: z.string().uuid().optional().nullable(),
|
||||
@@ -13,6 +20,7 @@ export const createIssueSchema = z.object({
|
||||
assigneeUserId: z.string().optional().nullable(),
|
||||
requestDepth: z.number().int().nonnegative().optional().default(0),
|
||||
billingCode: z.string().optional().nullable(),
|
||||
assigneeAdapterOverrides: issueAssigneeAdapterOverridesSchema.optional().nullable(),
|
||||
labelIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -68,10 +68,33 @@ interface WakeupOptions {
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ParsedIssueAssigneeAdapterOverrides {
|
||||
adapterConfig: Record<string, unknown> | null;
|
||||
useProjectWorkspace: boolean | null;
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function parseIssueAssigneeAdapterOverrides(
|
||||
raw: unknown,
|
||||
): ParsedIssueAssigneeAdapterOverrides | null {
|
||||
const parsed = parseObject(raw);
|
||||
const parsedAdapterConfig = parseObject(parsed.adapterConfig);
|
||||
const adapterConfig =
|
||||
Object.keys(parsedAdapterConfig).length > 0 ? parsedAdapterConfig : null;
|
||||
const useProjectWorkspace =
|
||||
typeof parsed.useProjectWorkspace === "boolean"
|
||||
? parsed.useProjectWorkspace
|
||||
: null;
|
||||
if (!adapterConfig && useProjectWorkspace === null) return null;
|
||||
return {
|
||||
adapterConfig,
|
||||
useProjectWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
function deriveTaskKey(
|
||||
contextSnapshot: Record<string, unknown> | null | undefined,
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
@@ -344,6 +367,7 @@ export function heartbeatService(db: Db) {
|
||||
agent: typeof agents.$inferSelect,
|
||||
context: Record<string, unknown>,
|
||||
previousSessionParams: Record<string, unknown> | null,
|
||||
opts?: { useProjectWorkspace?: boolean | null },
|
||||
) {
|
||||
const issueId = readNonEmptyString(context.issueId);
|
||||
const contextProjectId = readNonEmptyString(context.projectId);
|
||||
@@ -355,15 +379,17 @@ export function heartbeatService(db: Db) {
|
||||
.then((rows) => rows[0]?.projectId ?? null)
|
||||
: null;
|
||||
const resolvedProjectId = issueProjectId ?? contextProjectId;
|
||||
const useProjectWorkspace = opts?.useProjectWorkspace !== false;
|
||||
const workspaceProjectId = useProjectWorkspace ? resolvedProjectId : null;
|
||||
|
||||
const projectWorkspaceRows = resolvedProjectId
|
||||
const projectWorkspaceRows = workspaceProjectId
|
||||
? await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, agent.companyId),
|
||||
eq(projectWorkspaces.projectId, resolvedProjectId),
|
||||
eq(projectWorkspaces.projectId, workspaceProjectId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
|
||||
@@ -891,13 +917,35 @@ export function heartbeatService(db: Db) {
|
||||
const context = parseObject(run.contextSnapshot);
|
||||
const taskKey = deriveTaskKey(context, null);
|
||||
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
|
||||
const issueId = readNonEmptyString(context.issueId);
|
||||
const issueAssigneeConfig = issueId
|
||||
? await db
|
||||
.select({
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const issueAssigneeOverrides =
|
||||
issueAssigneeConfig && issueAssigneeConfig.assigneeAgentId === agent.id
|
||||
? parseIssueAssigneeAdapterOverrides(
|
||||
issueAssigneeConfig.assigneeAdapterOverrides,
|
||||
)
|
||||
: null;
|
||||
const taskSession = taskKey
|
||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
|
||||
: null;
|
||||
const previousSessionParams = normalizeSessionParams(
|
||||
sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null),
|
||||
);
|
||||
const resolvedWorkspace = await resolveWorkspaceForRun(agent, context, previousSessionParams);
|
||||
const resolvedWorkspace = await resolveWorkspaceForRun(
|
||||
agent,
|
||||
context,
|
||||
previousSessionParams,
|
||||
{ useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null },
|
||||
);
|
||||
context.paperclipWorkspace = {
|
||||
cwd: resolvedWorkspace.cwd,
|
||||
source: resolvedWorkspace.source,
|
||||
@@ -1016,9 +1064,12 @@ export function heartbeatService(db: Db) {
|
||||
};
|
||||
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||
? { ...config, ...issueAssigneeOverrides.adapterConfig }
|
||||
: config;
|
||||
const resolvedConfig = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
config,
|
||||
mergedConfig,
|
||||
);
|
||||
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
|
||||
await appendRunEvent(currentRun, seq++, {
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MoreHorizontal,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Minus,
|
||||
ArrowUp,
|
||||
@@ -47,6 +49,58 @@ interface IssueDraft {
|
||||
priority: string;
|
||||
assigneeId: string;
|
||||
projectId: string;
|
||||
assigneeModelOverride: string;
|
||||
assigneeThinkingEffort: string;
|
||||
assigneeUseProjectWorkspace: boolean;
|
||||
}
|
||||
|
||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local"]);
|
||||
|
||||
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
||||
claude_local: [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
],
|
||||
codex_local: [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "minimal", label: "Minimal" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
function buildAssigneeAdapterOverrides(input: {
|
||||
adapterType: string | null | undefined;
|
||||
modelOverride: string;
|
||||
thinkingEffortOverride: string;
|
||||
useProjectWorkspace: boolean;
|
||||
}): Record<string, unknown> | null {
|
||||
const adapterType = input.adapterType ?? null;
|
||||
if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const adapterConfig: Record<string, unknown> = {};
|
||||
if (input.modelOverride) adapterConfig.model = input.modelOverride;
|
||||
if (input.thinkingEffortOverride) {
|
||||
if (adapterType === "codex_local") {
|
||||
adapterConfig.modelReasoningEffort = input.thinkingEffortOverride;
|
||||
} else if (adapterType === "claude_local") {
|
||||
adapterConfig.effort = input.thinkingEffortOverride;
|
||||
}
|
||||
}
|
||||
|
||||
const overrides: Record<string, unknown> = {};
|
||||
if (Object.keys(adapterConfig).length > 0) {
|
||||
overrides.adapterConfig = adapterConfig;
|
||||
}
|
||||
if (!input.useProjectWorkspace) {
|
||||
overrides.useProjectWorkspace = false;
|
||||
}
|
||||
return Object.keys(overrides).length > 0 ? overrides : null;
|
||||
}
|
||||
|
||||
function loadDraft(): IssueDraft | null {
|
||||
@@ -93,6 +147,10 @@ export function NewIssueDialog() {
|
||||
const [priority, setPriority] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false);
|
||||
const [assigneeModelOverride, setAssigneeModelOverride] = useState("");
|
||||
const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState("");
|
||||
const [assigneeUseProjectWorkspace, setAssigneeUseProjectWorkspace] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -117,6 +175,17 @@ export function NewIssueDialog() {
|
||||
enabled: !!selectedCompanyId && newIssueOpen,
|
||||
});
|
||||
|
||||
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
|
||||
const supportsAssigneeOverrides = Boolean(
|
||||
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
||||
);
|
||||
|
||||
const { data: assigneeAdapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", assigneeAdapterType],
|
||||
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
|
||||
enabled: !!selectedCompanyId && newIssueOpen && supportsAssigneeOverrides,
|
||||
});
|
||||
|
||||
const createIssue = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
issuesApi.create(selectedCompanyId!, data),
|
||||
@@ -157,8 +226,30 @@ export function NewIssueDialog() {
|
||||
// Save draft on meaningful changes
|
||||
useEffect(() => {
|
||||
if (!newIssueOpen) return;
|
||||
scheduleSave({ title, description, status, priority, assigneeId, projectId });
|
||||
}, [title, description, status, priority, assigneeId, projectId, newIssueOpen, scheduleSave]);
|
||||
scheduleSave({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
assigneeId,
|
||||
projectId,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeUseProjectWorkspace,
|
||||
});
|
||||
}, [
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
assigneeId,
|
||||
projectId,
|
||||
assigneeModelOverride,
|
||||
assigneeThinkingEffort,
|
||||
assigneeUseProjectWorkspace,
|
||||
newIssueOpen,
|
||||
scheduleSave,
|
||||
]);
|
||||
|
||||
// Restore draft or apply defaults when dialog opens
|
||||
useEffect(() => {
|
||||
@@ -172,14 +263,38 @@ export function NewIssueDialog() {
|
||||
setPriority(draft.priority);
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId);
|
||||
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
|
||||
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
|
||||
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
|
||||
setAssigneeUseProjectWorkspace(draft.assigneeUseProjectWorkspace ?? true);
|
||||
} else {
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(newIssueDefaults.projectId ?? "");
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsAssigneeOverrides) {
|
||||
setAssigneeOptionsOpen(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const validThinkingValues =
|
||||
assigneeAdapterType === "codex_local"
|
||||
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
|
||||
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
||||
if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) {
|
||||
setAssigneeThinkingEffort("");
|
||||
}
|
||||
}, [supportsAssigneeOverrides, assigneeAdapterType, assigneeThinkingEffort]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -194,6 +309,10 @@ export function NewIssueDialog() {
|
||||
setPriority("");
|
||||
setAssigneeId("");
|
||||
setProjectId("");
|
||||
setAssigneeOptionsOpen(false);
|
||||
setAssigneeModelOverride("");
|
||||
setAssigneeThinkingEffort("");
|
||||
setAssigneeUseProjectWorkspace(true);
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
@@ -205,6 +324,12 @@ export function NewIssueDialog() {
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedCompanyId || !title.trim()) return;
|
||||
const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({
|
||||
adapterType: assigneeAdapterType,
|
||||
modelOverride: assigneeModelOverride,
|
||||
thinkingEffortOverride: assigneeThinkingEffort,
|
||||
useProjectWorkspace: assigneeUseProjectWorkspace,
|
||||
});
|
||||
createIssue.mutate({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
@@ -212,6 +337,7 @@ export function NewIssueDialog() {
|
||||
priority: priority || "medium",
|
||||
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(assigneeAdapterOverrides ? { assigneeAdapterOverrides } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,6 +368,16 @@ export function NewIssueDialog() {
|
||||
const currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
||||
const currentProject = (projects ?? []).find((p) => p.id === projectId);
|
||||
const assigneeOptionsTitle =
|
||||
assigneeAdapterType === "claude_local"
|
||||
? "Claude options"
|
||||
: assigneeAdapterType === "codex_local"
|
||||
? "Codex options"
|
||||
: "Agent options";
|
||||
const thinkingEffortOptions =
|
||||
assigneeAdapterType === "codex_local"
|
||||
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
|
||||
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
(agents ?? [])
|
||||
@@ -262,6 +398,15 @@ export function NewIssueDialog() {
|
||||
})),
|
||||
[projects],
|
||||
);
|
||||
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
(assigneeAdapterModels ?? []).map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
searchText: model.id,
|
||||
})),
|
||||
[assigneeAdapterModels],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -419,6 +564,68 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{supportsAssigneeOverrides && (
|
||||
<div className="px-4 pb-2 shrink-0">
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setAssigneeOptionsOpen((open) => !open)}
|
||||
>
|
||||
{assigneeOptionsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
{assigneeOptionsTitle}
|
||||
</button>
|
||||
{assigneeOptionsOpen && (
|
||||
<div className="mt-2 rounded-md border border-border p-3 bg-muted/20 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">Model</div>
|
||||
<InlineEntitySelector
|
||||
value={assigneeModelOverride}
|
||||
options={modelOverrideOptions}
|
||||
placeholder="Default model"
|
||||
noneLabel="Default model"
|
||||
searchPlaceholder="Search models..."
|
||||
emptyMessage="No models found."
|
||||
onChange={setAssigneeModelOverride}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">Thinking effort</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{thinkingEffortOptions.map((option) => (
|
||||
<button
|
||||
key={option.value || "default"}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-md text-xs border border-border hover:bg-accent/50 transition-colors",
|
||||
assigneeThinkingEffort === option.value && "bg-accent"
|
||||
)}
|
||||
onClick={() => setAssigneeThinkingEffort(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
||||
<div className="text-xs text-muted-foreground">Use project workspace</div>
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
assigneeUseProjectWorkspace ? "bg-green-600" : "bg-muted"
|
||||
)}
|
||||
onClick={() => setAssigneeUseProjectWorkspace((value) => !value)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
assigneeUseProjectWorkspace ? "translate-x-4.5" : "translate-x-0.5"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}>
|
||||
<MarkdownEditor
|
||||
|
||||
Reference in New Issue
Block a user