diff --git a/README.md b/README.md
index d9f77c5..930f631 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,60 @@ runner's scheduler, reuses any matching schedule and records the real checkout,
and approval-policy/layout paths. It reports a meaningful result or blocker, not empty periodic updates.
A cadence written in a profile does not itself run anything.
-### Profile, layout and approval settings
+### Models and thinking levels
+
+Agency separates **Discovery**, which finds and prepares new ideas, from **Execution**, which handles
+approved actions, feedback and Auto-improve. Choose an independent model and thinking level for each
+in Settings. On a New card, use the model controls to override the execution default for that card,
+then save before approving. New Task also supports an override. Resetting a card to **Use execution
+default** makes its next action inherit the saved execution profile again.
+
+The model list comes from the runner installed on your computer, including its supported thinking
+levels. For Codex, with Agency running:
+
+```sh
+RADAR_URL=http://localhost:3100 npm run models:sync
+```
+
+This reads the [Codex App Server model catalog](https://learn.chatgpt.com/docs/app-server#models).
+It does not start inference, copy credentials, change your global Codex settings or create a worker.
+Refresh it after a runner update. An unavailable saved choice remains visible and blocks new work
+until you select a valid replacement. Other runners can register their own catalog with
+`POST /api/agent-models`; a catalog entry is not proof that a coordinator for that runner is installed.
+
+Without an explicit profile, Agency uses the registered runner default. Existing installations with
+no registered catalog retain their previous coordinator workflow until models are configured.
+
+The active coordinator still launches workers. Saving a setting or clicking a card does not install
+a background runner. The selected profile is captured when a job is queued; changing a default later
+does not alter that queued job. A worker must claim the job with the exact model, thinking level and
+a fresh context. The app keeps the requested selection separate from claimed worker metadata.
+
+For a Codex coordinator, prepare a brief containing the scope, project and resolved skill/profile/
+policy/layout paths, then obtain the host's fresh-worker arguments:
+
+```sh
+RADAR_URL=http://localhost:3100 npm run agent:dispatch -- --phase discovery --brief agent-work/discovery.md
+RADAR_URL=http://localhost:3100 npm run agent:dispatch -- --job 123 --brief agent-work/execution.md
+```
+
+These commands print a dispatch plan; they do not execute or claim work. The coordinator invokes its
+`spawn_agent` tool with the returned unique task name, `model`, `reasoning_effort` and
+`fork_turns: "none"`. A job worker waits for claim confirmation. The coordinator supplies the actual
+worker identifier with the claim, confirms it to the worker, and later verifies the result. A host
+that cannot honor the selected model must report that limitation instead of inheriting the parent.
+If the claim fails, stop the waiting worker rather than authorizing it to proceed.
+Keep dispatch output private because it includes the job's original context and approval.
+
+Model selection does not widen the approved action, change account access or authorize a schedule.
+
+Run `npm test`, `npm run typecheck`, `npm run lint` and `npm run build` to check the source. The
+optional `AGENCY_TEST_URL=http://localhost:3101 npm run test:integration` exercises the real local
+database API on a separate fixture server. It refuses the everyday port 3100 and a personal profile,
+restores model settings and dismisses its synthetic cards. API tests do not perform model inference;
+verify your coordinator separately with a bounded local worker before using it for real actions.
+
+### Profile, layout and approval files
| File | Purpose |
| --- | --- |
@@ -232,12 +285,30 @@ Include `x-radar-local-agent: 1` for local agent requests.
- `GET /api/agent-jobs` returns available latest jobs, not an exclusive claim. Read `cardContext`,
`userFeedback`, `buttonLabel`, `instruction` and ordered `history`. History results are truncated
to 600 characters; use full local history read-only when a missing detail matters.
+- Jobs also return `agentConfig` (the immutable queued selection) and `agentRun` (claimed worker
+ metadata). For a configured job, a running update requires `agentRun` with matching `runner`,
+ `model`, `thinkingLevel` and `contextMode: "fresh"`. Include the actual host's `workerId` once known.
+ Missing or mismatched metadata is rejected. Earlier jobs without a profile remain compatible.
+- If the selected runtime cannot start, report a truthful preflight failure with the queued job's
+ `id`, `status: "failed"`, `ticketOutcome: "blocked"`, `failureStage: "dispatch"` and a nonempty
+ `result`. Omit `agentRun`. This returns the unstarted job as visibly blocked without pretending a
+ worker ran. It cannot complete a queued job or replace an already-running worker's outcome.
+- `GET /api/agent-settings` reads the current catalog and separate defaults. Save defaults with
+ `POST /api/agent-settings`, providing `expectedRevision`, `discovery` and `execution`. A selection
+ is `{modelId, thinkingLevel}`; `null` uses the runner default. Refresh on a 409 conflict.
+- `POST /api/agent-models` registers `{models, runnerDefault, expectedRevision}` through the local
+ agent interface. Each model has `id`, `runner`, `model`, `label`, `thinkingLevels` and
+ `defaultThinkingLevel`. No model names are built into the app.
+- `POST /api/ideas/agent` saves `{id, version, expectedAgentRevision, selection}` on an idle New card.
+ It preserves the card's content, ordering, approval and completion state. UI action requests also
+ include `expectedAgentRevision` and `expectedSettingsRevision` to reject stale selections.
- `GET /api/state` exposes current context, cards and decisions with view/light-dependent coverage.
Inspect its response and route before treating it as the complete archive. Search all statuses,
versions, feedback, jobs and source anchors for duplicates; fall back to bounded read-only local
database queries when necessary.
-- One coordinator assigns each job. `POST /api/agent-jobs` with
- `{"id":123,"status":"running"}` starts work; repeated running updates renew its six-hour lease.
+- One coordinator assigns each job. `POST /api/agent-jobs` with the job's `id`, `status: "running"`
+ and matching `agentRun` starts work; repeated running updates renew its six-hour lease. The bare
+ `{"id":123,"status":"running"}` format applies only to legacy jobs without an `agentConfig`.
GET may return expired running work as `reclaimed: true`, subject to ten concurrent slots.
The API has no exclusive worker token. Coordinate other active agents and recheck live state;
do not assume a successful running update prevents another worker from acting.
diff --git a/app/agency.tsx b/app/agency.tsx
index 5e39054..dcc86e4 100644
--- a/app/agency.tsx
+++ b/app/agency.tsx
@@ -7,6 +7,20 @@ import { cardShortcut } from "../lib/card-shortcut";
import { clusterForCard, type Topic } from "../lib/card-cluster";
import { compareByImpact, impactPoints } from "../lib/rise";
import { MAX_TASK_LENGTH, submitNewTask } from "../lib/task-submission";
+import { AgentPicker, selectionLabel, selectionSummary } from "./components/agent-picker";
+import { ModelControls } from "./components/model-controls";
+import {
+ cardAgentDraftState,
+ discardCardAgentDraft,
+ finishCardAgentDraftSave,
+ reapplyCardAgentDraft,
+ selectionCanRun,
+ selectionCanStartWork,
+ selectionValidity,
+ updateCardAgentDraft,
+ type CardAgentDrafts,
+} from "./components/agent-picker-state";
+import type { AgentConfig, AgentRun, AgentSelection, AgentSettings } from "../lib/agent-models";
type Idea = {
id: number;
@@ -37,6 +51,10 @@ type Idea = {
decisionAction: "do" | "change" | "no" | null;
decisionEstimateMs: number | null;
decisionEstimateReason: string;
+ agentSelection: AgentSelection | null;
+ agentRevision: number;
+ agentConfig: AgentConfig | null;
+ agentRun: AgentRun | null;
};
type RadarState = {
@@ -58,6 +76,7 @@ type RadarState = {
medianFirstActionMs: number | null;
medianEstimateErrorMs: number | null;
};
+ agentSettings: AgentSettings;
};
type CardAction = {
@@ -95,6 +114,7 @@ const emptyState: RadarState = {
medianFirstActionMs: null,
medianEstimateErrorMs: null,
},
+ agentSettings: { revision: 0, models: [], runnerDefault: null, discovery: null, execution: null },
};
function formatDuration(milliseconds: number | null) {
@@ -112,6 +132,23 @@ function decisionLabel(action: Idea["decisionAction"]) {
return "Changed";
}
+function configLabel(config: AgentConfig | null) {
+ if (!config) return "No model was requested";
+ return `${config.runner} / ${config.model} · ${config.thinkingLevel}`;
+}
+
+function runLabel(run: AgentRun | null) {
+ if (!run) return "Not claimed yet";
+ return `${run.runner} / ${run.model} · ${run.thinkingLevel}`;
+}
+
+
+function AgentReadout({ config, run, label = "Model details" }: { config: AgentConfig | null; run: AgentRun | null; label?: string }) {
+ return
+ {label}
+ Requested{configLabel(config)}Actual{runLabel(run)}
+ ;
+}
type SortKey = "newest" | "score" | "effort";
type SortMode = { key: SortKey; dir: "desc" | "asc" };
@@ -296,6 +333,7 @@ function DoneList({ ideas, topics, onAction, onInteraction }: { ideas: Idea[]; t
{open && (
{idea.jobResult &&
{summarizeJobResult(idea.jobResult)}
}
+ {(idea.agentConfig || idea.agentRun) &&
}
{cardHtml[idea.id] || idea.cardHtml
?
onAction(idea, action)} onInteraction={(action, label) => onInteraction(idea, action, label)} />
: Loading card…
}
@@ -316,6 +354,7 @@ function DoneList({ ideas, topics, onAction, onInteraction }: { ideas: Idea[]; t
export function Agency() {
const [data, setData] = useState(emptyState);
const [view, setView] = useState<"new" | "working" | "done">("new");
+ const viewRef = useRef<"new" | "working" | "done">("new");
const [cluster, setCluster] = useState("all");
const [sort, setSort] = useState(readSortMode);
const sortRef = useRef(sort);
@@ -323,6 +362,7 @@ export function Agency() {
sortRef.current = sort;
try { window.localStorage.setItem(SORT_KEY, JSON.stringify(sort)); } catch { /* private mode */ }
}, [sort]);
+ useEffect(() => { viewRef.current = view; }, [view]);
const [selectedIdea, setSelectedIdea] = useState(null);
// Poll responses may resolve after the user has already moved to another card.
// Keep the navigation anchor outside React's render timing so a refresh can
@@ -331,21 +371,28 @@ export function Agency() {
const [composer, setComposer] = useState<"task" | "context" | null>(null);
const [contextDraft, setContextDraft] = useState("");
const [taskDraft, setTaskDraft] = useState("");
+ const [taskAgentSelection, setTaskAgentSelection] = useState(null);
const [taskSubmitting, setTaskSubmitting] = useState(false);
const taskSubmittingRef = useRef(false);
const [composerError, setComposerError] = useState("");
const [feedbackDrafts, setFeedbackDrafts] = useState>({});
const [feedbackSubmitting, setFeedbackSubmitting] = useState(false);
+ const [agentSelectionDrafts, setAgentSelectionDrafts] = useState({});
+ const [agentSavingKey, setAgentSavingKey] = useState("");
+ const [agentSettingsRefreshing, setAgentSettingsRefreshing] = useState(false);
+ const [agentSelectionError, setAgentSelectionError] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(true);
const [, setLiveDecision] = useState({ key: "", activeMs: 0 });
const liveDecisionByCardRef = useRef>({});
const attentionTrackerRef = useRef(null);
const loadRequestRef = useRef(0);
+ const agentSettingsRefreshAfterRef = useRef(0);
// `?card=` opens one exact card on first load, whatever lane it sits in.
const deepLinkHandledRef = useRef(false);
const selectIdea = useCallback((idea: Idea | null) => {
+ if (selectedIdeaRef.current?.id !== idea?.id || selectedIdeaRef.current?.agentRevision !== idea?.agentRevision) setAgentSelectionError("");
selectedIdeaRef.current = idea;
setSelectedIdea(idea);
}, []);
@@ -365,7 +412,7 @@ export function Agency() {
if (requestedCardId) stateUrl.searchParams.set("card", String(requestedCardId));
const response = await fetch(stateUrl, { cache: "no-store" });
const next = (await response.json()) as RadarState;
- if (requestId !== loadRequestRef.current) return;
+ if (requestId !== loadRequestRef.current) return false;
if (!deepLinkHandledRef.current) {
deepLinkHandledRef.current = true;
const requestedId = Number(new URLSearchParams(window.location.search).get("card"));
@@ -375,7 +422,11 @@ export function Agency() {
setView(requested.status);
selectIdea(requested);
setLoading(false);
- return;
+ if (agentSettingsRefreshAfterRef.current && requestId >= agentSettingsRefreshAfterRef.current) {
+ agentSettingsRefreshAfterRef.current = 0;
+ setAgentSettingsRefreshing(false);
+ }
+ return true;
}
}
const visible = ideasForView(next.ideas, targetView, sortRef.current).filter((idea) => idea.id !== selection?.excludeId);
@@ -383,8 +434,31 @@ export function Agency() {
const anchor = selection ? selection.preferred : selectedIdeaRef.current;
selectIdea(keepSelectedCard(anchor, visible, next.ideas));
setLoading(false);
+ if (agentSettingsRefreshAfterRef.current && requestId >= agentSettingsRefreshAfterRef.current) {
+ agentSettingsRefreshAfterRef.current = 0;
+ setAgentSettingsRefreshing(false);
+ }
+ return true;
}, [selectIdea, view]);
+ const refreshAgentSettingsSnapshot = useCallback(async () => {
+ setAgentSettingsRefreshing(true);
+ agentSettingsRefreshAfterRef.current = loadRequestRef.current + 1;
+ try {
+ await load(viewRef.current);
+ return agentSettingsRefreshAfterRef.current === 0;
+ } catch {
+ return false;
+ }
+ }, [load]);
+
+ const retryAgentSettingsSnapshot = useCallback(async () => {
+ setAgentSelectionError("");
+ if (!await refreshAgentSettingsSnapshot()) {
+ setAgentSelectionError("Could not load the latest model defaults. Retry the refresh before starting work.");
+ }
+ }, [refreshAgentSettingsSnapshot]);
+
useEffect(() => {
if (typeof window === "undefined") return;
if (!selectedIdea) return;
@@ -437,6 +511,28 @@ export function Agency() {
const feedbackKey = active ? cardDraftKey(active) : "";
const feedback = feedbackKey ? feedbackDrafts[feedbackKey] ?? "" : "";
const activeLiveState = latestSelected ?? active;
+ const activeAgentKey = active ? String(active.id) : "";
+ const activeAgentDraft = active ? cardAgentDraftState(active, agentSelectionDrafts) : null;
+ const activeAgentSelection = activeAgentDraft?.selection ?? null;
+ const activeAgentValidity = selectionValidity(data.agentSettings.models, activeAgentSelection);
+ const agentSelectionSaving = Boolean(activeAgentKey && agentSavingKey === activeAgentKey);
+ const agentSelectionDirty = Boolean(activeAgentDraft?.dirty);
+ const agentSelectionStale = Boolean(activeAgentDraft?.stale);
+ const inheritedExecutionSelection = data.agentSettings.execution ?? data.agentSettings.runnerDefault;
+ const activeEffectiveSelection = activeAgentSelection ?? inheritedExecutionSelection;
+ const cardConfigUnavailable = Boolean(active && !selectionCanStartWork(data.agentSettings.models, activeEffectiveSelection, agentSettingsRefreshing));
+ const effectiveSelectionUnavailable = activeEffectiveSelection !== null && !selectionCanRun(data.agentSettings.models, activeEffectiveSelection);
+ const cardAgentBlocked = Boolean(activeAgentDraft?.blocksWork) || agentSelectionSaving || agentSettingsRefreshing || !activeAgentValidity.valid || cardConfigUnavailable;
+ const executionInheritedLabel = "Use execution default";
+ const executionEffectiveLabel = selectionLabel(data.agentSettings.models, inheritedExecutionSelection, "No execution default configured");
+ const taskAgentAvailable = selectionCanStartWork(data.agentSettings.models, taskAgentSelection ?? inheritedExecutionSelection, agentSettingsRefreshing);
+ const cardModelNotice = agentSelectionSaving ? "Saving model choice…" : agentSettingsRefreshing ? "Refreshing defaults. Work is paused."
+ : agentSelectionStale ? "This card changed. Your model choice is kept. Review it before continuing."
+ : !activeAgentValidity.valid ? activeAgentValidity.message
+ : cardConfigUnavailable ? effectiveSelectionUnavailable ? "Saved model unavailable. Choose a model to continue." : "Choose a model. No execution default is available."
+ : agentSelectionDirty ? "Unsaved model choice. Save to continue." : "";
+ const taskModelNotice = agentSettingsRefreshing ? "Refreshing model defaults…" : !taskAgentAvailable
+ ? selectionValidity(data.agentSettings.models, taskAgentSelection).message || "Choose an available model or execution default before sending this task." : "";
const activeJob = activeLiveState?.jobId ? {
id: activeLiveState.jobId,
status: activeLiveState.jobStatus,
@@ -445,6 +541,9 @@ export function Agency() {
label: activeLiveState.jobLabel?.trim() ?? "",
} : null;
const jobInFlight = activeJob?.status === "queued" || activeJob?.status === "running";
+ const blockedTaskResult = activeJob?.outcome === "blocked" && !jobInFlight
+ ? activeJob.result || "This task is blocked."
+ : "";
const attentionIdeaId = active?.id ?? null;
const attentionIdeaVersion = active?.version ?? null;
const attentionDecisionAction = active?.decisionAction ?? null;
@@ -472,6 +571,7 @@ export function Agency() {
if (attentionIdeaId === null || attentionIdeaVersion === null || attentionDecisionAction || composer) return;
const id = attentionIdeaId;
const version = attentionIdeaVersion;
+ // eslint-disable-next-line react-hooks/purity -- this timer baseline is created only after the effect mounts.
const now = Date.now();
const totalActiveMs = Math.max(attentionInitialActiveMs, liveDecisionByCardRef.current[`${id}:${version}`] ?? 0);
const tracker: AttentionTracker = { id, version, lastInteractionAt: now, lastTickAt: now, pendingActiveMs: 0, totalActiveMs };
@@ -522,12 +622,72 @@ export function Agency() {
}).catch(() => undefined);
}, [takePendingActiveMs]);
+ const saveCardAgentSelection = useCallback(async () => {
+ const target = selectedIdeaRef.current;
+ if (!target) return false;
+ const key = String(target.id);
+ const draftState = cardAgentDraftState(target, agentSelectionDrafts);
+ if (agentSettingsRefreshing || agentSavingKey === key) return false;
+ if (!draftState.draft) return true;
+ if (draftState.stale) {
+ setAgentSelectionError("This card changed. Review your model choice, then keep it or discard it before saving.");
+ return false;
+ }
+ const draft = draftState.draft;
+ const selection = draft.selection;
+ const validity = selectionValidity(data.agentSettings.models, selection);
+ if (!validity.valid) return false;
+ // Ignore a state refresh that began before this compare-and-swap save. It
+ // may contain the prior agent revision and would otherwise reopen a stale
+ // draft after the server has accepted this one.
+ ++loadRequestRef.current;
+ setAgentSavingKey(key);
+ setAgentSettingsRefreshing(true);
+ setAgentSelectionError("");
+ try {
+ const response = await fetch("/api/ideas/agent", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ id: target.id, version: draft.expectedVersion, expectedAgentRevision: draft.expectedAgentRevision, selection }),
+ });
+ const result = await response.json().catch(() => null) as {
+ ok?: boolean;
+ agentRevision?: number;
+ error?: string;
+ } | null;
+ if (!response.ok || !result?.ok || typeof result.agentRevision !== "number" || !Number.isInteger(result.agentRevision)) {
+ throw new Error(result?.error || "This model choice was not saved.");
+ }
+ if (await refreshAgentSettingsSnapshot()) {
+ setAgentSelectionDrafts((current) => finishCardAgentDraftSave(current, target.id, draft));
+ } else {
+ setAgentSelectionError("The model choice was saved, but the latest defaults could not be loaded. Retry the refresh before starting work.");
+ }
+ return true;
+ } catch (error) {
+ setAgentSelectionError(error instanceof Error ? error.message : "This model choice was not saved.");
+ if (!await refreshAgentSettingsSnapshot()) {
+ setAgentSelectionError("Could not confirm the latest model defaults. Retry the refresh before starting work.");
+ }
+ return false;
+ } finally {
+ setAgentSavingKey((current) => current === key ? "" : current);
+ }
+ }, [agentSavingKey, agentSelectionDrafts, agentSettingsRefreshing, data.agentSettings.models, refreshAgentSettingsSnapshot]);
+
const sendToAgent = useCallback(async (target: Idea, action: "do" | "change" | "no", label: string, prompt = "", note = "") => {
+ const targetKey = String(target.id);
+ const targetDraft = cardAgentDraftState(target, agentSelectionDrafts);
+ const targetSelection = targetDraft.selection;
+ if (action !== "no" && (agentSettingsRefreshing || agentSavingKey === targetKey || targetDraft.blocksWork || !selectionValidity(data.agentSettings.models, targetSelection).valid || !selectionCanRun(data.agentSettings.models, targetSelection ?? inheritedExecutionSelection))) {
+ setAgentSelectionError("Save a valid model choice before starting this work.");
+ return false;
+ }
const activeMs = takePendingActiveMs(target.id, target.version);
const response = await fetch("/api/ideas/action", {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ id: target.id, version: target.version, status: target.status, action, label, prompt, note, activeMs }),
+ body: JSON.stringify({ id: target.id, version: target.version, status: target.status, action, label, prompt, note, activeMs, expectedAgentRevision: target.agentRevision, expectedSettingsRevision: data.agentSettings.revision }),
});
if (response.status === 409) {
const tracker = attentionTrackerRef.current;
@@ -556,30 +716,31 @@ export function Agency() {
const nextSelection = targetView === view
? nextCardAfterRemoval(target.id, visibleIdeas)
: ideasForView(data.ideas, targetView, sortRef.current)[0] ?? null;
+ viewRef.current = targetView;
setView(targetView);
selectIdea(nextSelection);
await load(targetView, { preferred: nextSelection, excludeId: target.id });
return true;
- }, [data.ideas, load, selectIdea, takePendingActiveMs, view, visibleIdeas]);
-
- const handleCardAction = useCallback((payload: CardAction) => {
- if (!active) return;
- const label = payload.label?.slice(0, 120) || payload.action;
- const prompt = payload.prompt?.slice(0, 5000) || "";
- if (payload.action === "open") {
- if (!payload.url) return;
- recordCardInteraction(active, "open", label);
- const url = new URL(payload.url, window.location.origin);
- if (url.protocol === "http:" || url.protocol === "https:") window.open(url.href, "_blank", "noopener,noreferrer");
- return;
- }
- void sendToAgent(active, payload.action, label, prompt);
- }, [active, recordCardInteraction, sendToAgent]);
+ }, [agentSavingKey, agentSelectionDrafts, agentSettingsRefreshing, data.agentSettings.models, data.agentSettings.revision, data.ideas, inheritedExecutionSelection, load, selectIdea, takePendingActiveMs, view, visibleIdeas]);
+
+ function handleCardAction(payload: CardAction) {
+ if (!active) return;
+ const label = payload.label?.slice(0, 120) || payload.action;
+ const prompt = payload.prompt?.slice(0, 5000) || "";
+ if (payload.action === "open") {
+ if (!payload.url) return;
+ recordCardInteraction(active, "open", label);
+ const url = new URL(payload.url, window.location.origin);
+ if (url.protocol === "http:" || url.protocol === "https:") window.open(url.href, "_blank", "noopener,noreferrer");
+ return;
+ }
+ void sendToAgent(active, payload.action, label, prompt);
+ }
const submitFeedback = useCallback(async () => {
const note = feedback.trim();
const target = active;
- if (!target || !note || jobInFlight || feedbackSubmitting) return;
+ if (!target || !note || jobInFlight || feedbackSubmitting || cardAgentBlocked) return;
setFeedbackSubmitting(true);
try {
@@ -593,10 +754,10 @@ export function Agency() {
} finally {
setFeedbackSubmitting(false);
}
- }, [active, feedback, feedbackSubmitting, jobInFlight, sendToAgent]);
+ }, [active, cardAgentBlocked, feedback, feedbackSubmitting, jobInFlight, sendToAgent]);
const submitImprove = useCallback(async () => {
- if (!active || jobInFlight || feedbackSubmitting) return;
+ if (!active || jobInFlight || feedbackSubmitting || cardAgentBlocked) return;
setFeedbackSubmitting(true);
try {
@@ -609,7 +770,7 @@ export function Agency() {
} finally {
setFeedbackSubmitting(false);
}
- }, [active, feedbackSubmitting, jobInFlight, sendToAgent]);
+ }, [active, cardAgentBlocked, feedbackSubmitting, jobInFlight, sendToAgent]);
const submitSkip = useCallback(async () => {
if (!active || feedbackSubmitting) return;
@@ -622,14 +783,38 @@ export function Agency() {
}
}, [active, feedbackSubmitting, sendToAgent]);
+ function move(direction: number) {
+ if (!visibleIdeas.length) return;
+ recordCardInteraction(active, direction > 0 ? "next" : "back", direction > 0 ? "Next card" : "Previous card");
+ const currentIndex = active ? visibleIdeas.findIndex((idea) => idea.id === active.id) : -1;
+ const startingIndex = currentIndex >= 0 ? currentIndex : direction > 0 ? -1 : 0;
+ const nextIndex = (startingIndex + direction + visibleIdeas.length) % visibleIdeas.length;
+ selectIdea(visibleIdeas[nextIndex]);
+ setMessage("");
+ }
+
useEffect(() => {
- if (!active || composer) return;
+ if (!active && !composer) return;
+ const moveWithKeyboard = (direction: number) => {
+ if (!active || !visibleIdeas.length) return;
+ recordCardInteraction(active, direction > 0 ? "next" : "back", direction > 0 ? "Next card" : "Previous card");
+ const currentIndex = visibleIdeas.findIndex((idea) => idea.id === active.id);
+ const startingIndex = currentIndex >= 0 ? currentIndex : direction > 0 ? -1 : 0;
+ const nextIndex = (startingIndex + direction + visibleIdeas.length) % visibleIdeas.length;
+ selectIdea(visibleIdeas[nextIndex]);
+ setMessage("");
+ };
const shortcut = (event: KeyboardEvent) => {
+ // An open popover owns Escape. Once closed, Escape can dismiss New Task.
+ if (document.querySelector(".model-controls-panel:popover-open")) return;
if (event.key === "Escape" && composer) {
event.preventDefault();
setComposer(null);
return;
}
+ if (composer || !active) return;
+ // Closed model controls still own Enter and typing, not card shortcuts.
+ if (event.composedPath().some((target) => target instanceof HTMLElement && target.closest(".model-controls, .radar-card-agent-readout"))) return;
const action = cardShortcut({
key: event.key,
editable: event.composedPath().some((target) => target instanceof HTMLElement
@@ -648,10 +833,10 @@ export function Agency() {
void submitImprove();
} else if (action === "previous") {
event.preventDefault();
- move(-1);
+ moveWithKeyboard(-1);
} else if (action === "next") {
event.preventDefault();
- move(1);
+ moveWithKeyboard(1);
} else if (action === "focus") {
const box = document.querySelector(".radar-inline-change textarea");
if (box && !box.disabled) {
@@ -662,7 +847,7 @@ export function Agency() {
};
window.addEventListener("keydown", shortcut);
return () => window.removeEventListener("keydown", shortcut);
- }, [active, composer, submitImprove, submitSkip]);
+ }, [active, composer, recordCardInteraction, selectIdea, submitImprove, submitSkip, visibleIdeas]);
async function submitTell() {
@@ -675,14 +860,21 @@ export function Agency() {
setComposerError("");
try {
if (composer === "task") {
- const { jobId } = await submitNewTask(task);
+ const taskSelectionValidity = selectionValidity(data.agentSettings.models, taskAgentSelection);
+ if (!taskSelectionValidity.valid || !taskAgentAvailable) {
+ setComposerError(taskSelectionValidity.valid ? "Choose an execution default or a model override before sending this task." : taskSelectionValidity.message);
+ return;
+ }
+ const { jobId } = await submitNewTask(task, { agentSelection: taskAgentSelection, expectedSettingsRevision: data.agentSettings.revision });
const newIdeas = ideasForView(data.ideas, "new", sort);
const filtered = newIdeas.filter((idea) => cluster === "all" || clusterForCard(idea, data.topics) === cluster);
const candidates = filtered.length ? filtered : newIdeas;
const next = active ? nextCardAfterRemoval(active.id, candidates) ?? candidates[0] ?? null : candidates[0] ?? null;
if (!filtered.length) setCluster("all");
setTaskDraft("");
+ setTaskAgentSelection(null);
setComposer(null);
+ viewRef.current = "new";
setView("new");
selectIdea(next);
setMessage(`Task queued · #${jobId}. You can keep reviewing.`);
@@ -707,19 +899,10 @@ export function Agency() {
}
}
- function move(direction: number) {
- if (!visibleIdeas.length) return;
- recordCardInteraction(active, direction > 0 ? "next" : "back", direction > 0 ? "Next card" : "Previous card");
- const currentIndex = active ? visibleIdeas.findIndex((idea) => idea.id === active.id) : -1;
- const startingIndex = currentIndex >= 0 ? currentIndex : direction > 0 ? -1 : 0;
- const nextIndex = (startingIndex + direction + visibleIdeas.length) % visibleIdeas.length;
- selectIdea(visibleIdeas[nextIndex]);
- setMessage("");
- }
-
function selectView(next: "new" | "working" | "done") {
setComposer(null);
recordCardInteraction(active, "lane", next);
+ viewRef.current = next;
setView(next);
selectIdea(null);
setMessage("");
@@ -736,6 +919,7 @@ export function Agency() {
setMessage("");
setComposerError("");
setContextDraft(data.context?.text ?? "");
+ setTaskAgentSelection(null);
setComposer("task");
}
@@ -820,10 +1004,24 @@ export function Agency() {
New task