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