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:
Forgotten
2026-02-26 10:32:44 -06:00
parent 1e11806fa3
commit e4e5609132
10 changed files with 5899 additions and 6 deletions

View File

@@ -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