diff --git a/client/src/components/cos/TaskAddForm.jsx b/client/src/components/cos/TaskAddForm.jsx index 154efe03d4..4e16be46be 100644 --- a/client/src/components/cos/TaskAddForm.jsx +++ b/client/src/components/cos/TaskAddForm.jsx @@ -37,7 +37,7 @@ const readTaskDescriptionDraft = (defaultApp) => { }; }; -export default function TaskAddForm({ providers, apps, onTaskAdded, compact = false, defaultExpanded = false, defaultApp = '' }) { +export default function TaskAddForm({ providers, providersLoaded = true, apps, onTaskAdded, compact = false, defaultExpanded = false, defaultApp = '' }) { const [initialDraft] = useState(() => readTaskDescriptionDraft(defaultApp)); const [newTask, setNewTask] = useState(() => { return { @@ -180,12 +180,16 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa // If the pinned provider isn't a valid coding option (e.g. a saved template // pinned an `api` provider that's now filtered out of the dropdown), reset to - // "Auto" so the visible select and the submitted value can't diverge. + // "Auto" so the visible select and the submitted value can't diverge. Gated on + // `providersLoaded`: mid-fetch, `enabledProviders` is always empty, so without + // the gate this would wipe out a legitimately pinned provider (a draft/template + // restored before the list has arrived) before it ever gets a chance to match. useEffect(() => { + if (!providersLoaded) return; if (newTask.provider && !enabledProviders.some(p => p.id === newTask.provider)) { setNewTask(t => ({ ...t, provider: '', model: '', effort: '', temperature: '', thinking: '' })); } - }, [enabledProviders, newTask.provider]); + }, [enabledProviders, newTask.provider, providersLoaded]); // Check if selected app has JIRA configured const selectedApp = useMemo(() => @@ -948,9 +952,12 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa value={newTask.provider} onChange={e => setNewTask(t => ({ ...t, provider: e.target.value, model: '', effort: '', temperature: '', thinking: '' }))} className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm min-h-[44px]" + disabled={!providersLoaded} > - - {enabledProviders.map(p => ( + {providersLoaded + ? + : } + {providersLoaded && enabledProviders.map(p => ( ))} diff --git a/client/src/components/cos/tabs/AgentsTab.jsx b/client/src/components/cos/tabs/AgentsTab.jsx index 3b14af18e2..a9658699f6 100644 --- a/client/src/components/cos/tabs/AgentsTab.jsx +++ b/client/src/components/cos/tabs/AgentsTab.jsx @@ -39,7 +39,7 @@ const needsAgentFeedback = (agent) => { return !isSystemAgent && isManualUserAgent && !agent.feedback?.rating; }; -export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, apps }) { +export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, providersLoaded, apps }) { const [searchParams, setSearchParams] = useSearchParams(); const [resumingAgent, setResumingAgent] = useState(null); const [relaunchingAgent, setRelaunchingAgent] = useState(null); @@ -435,6 +435,7 @@ export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, a setRelaunchingAgent(null)} @@ -447,6 +448,7 @@ export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, a agent={resumingAgent} taskType={resumingAgent.taskId?.startsWith('sys-') || resumingAgent.metadata?.taskType === 'internal' ? 'internal' : 'user'} providers={providers} + providersLoaded={providersLoaded} apps={apps} onSubmit={handleResumeSubmit} onClose={() => setResumingAgent(null)} diff --git a/client/src/components/cos/tabs/RelaunchAgentModal.jsx b/client/src/components/cos/tabs/RelaunchAgentModal.jsx index 1c9a1b4db1..d5041a0f32 100644 --- a/client/src/components/cos/tabs/RelaunchAgentModal.jsx +++ b/client/src/components/cos/tabs/RelaunchAgentModal.jsx @@ -42,7 +42,7 @@ const RELAUNCH_MESSAGES = { * because it is mounted from two places (the agent card and the in-progress task * card) and the server's mode enum should have exactly one client reader. */ -export default function RelaunchAgentModal({ agent, providers, apps, onDone, onClose }) { +export default function RelaunchAgentModal({ agent, providers, providersLoaded = true, apps, onDone, onClose }) { const currentProvider = agent?.metadata?.providerId || agent?.metadata?.provider || ''; const taskDescription = agent?.metadata?.taskDescription || agent?.taskId || 'Current task'; @@ -163,6 +163,7 @@ export default function RelaunchAgentModal({ agent, providers, apps, onDone, onC emptyModelOption="Default model" alwaysShowModel highlightToolUse + loading={!providersLoaded} /> diff --git a/client/src/components/cos/tabs/ResumeAgentModal.jsx b/client/src/components/cos/tabs/ResumeAgentModal.jsx index 5585202ba2..d56802876b 100644 --- a/client/src/components/cos/tabs/ResumeAgentModal.jsx +++ b/client/src/components/cos/tabs/ResumeAgentModal.jsx @@ -8,7 +8,7 @@ import { FormField } from '../../ui/FormField'; import EffortSelect from '../EffortSelect'; import { effectiveModelFor, effortAwareModelOptions, effortSurvivingModel, seedModelEffort } from '../../../utils/providers'; -export default function ResumeAgentModal({ agent, taskType = 'user', providers, apps, onSubmit, onClose }) { +export default function ResumeAgentModal({ agent, taskType = 'user', providers, providersLoaded = true, apps, onSubmit, onClose }) { // A paused agent resumes IN PLACE: its own task is requeued on the worktree its // run left behind. Everything else (a completed/failed run, whose task is long // settled) can only be continued by queueing a new task. @@ -232,9 +232,14 @@ export default function ResumeAgentModal({ agent, taskType = 'user', providers, value={formData.provider} onChange={e => setFormData({ ...formData, provider: e.target.value, model: '', effort: '' })} className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm focus:border-port-accent focus:outline-hidden" + disabled={!providersLoaded} > - - {providers?.filter(p => p.enabled).map(p => ( + {/* Mid-fetch, `providers` is empty — say so instead of rendering a + picker whose only option looks like a broken control. */} + {providersLoaded + ? + : } + {providersLoaded && providers?.filter(p => p.enabled).map(p => ( ))} @@ -250,9 +255,9 @@ export default function ResumeAgentModal({ agent, taskType = 'user', providers, effort: effortSurvivingModel(selectedProvider, e.target.value, d.effort), }))} className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm focus:border-port-accent focus:outline-hidden" - disabled={!formData.provider} + disabled={!providersLoaded || !formData.provider} > - + {availableModels.map(m => ( ))} diff --git a/client/src/components/cos/tabs/SortableTaskItem.jsx b/client/src/components/cos/tabs/SortableTaskItem.jsx index 84b259c425..cafd21fd2f 100644 --- a/client/src/components/cos/tabs/SortableTaskItem.jsx +++ b/client/src/components/cos/tabs/SortableTaskItem.jsx @@ -3,7 +3,7 @@ import { useSortable } from '@dnd-kit/sortable'; import { dndTransformToCss } from '../../../lib/dndTransform'; import TaskItem from './TaskItem'; -export default function SortableTaskItem({ task, selected = false, onRefresh, providers, durations, apps, instances }) { +export default function SortableTaskItem({ task, selected = false, onRefresh, providers, providersLoaded, durations, apps, instances }) { const [isEditing, setIsEditing] = useState(false); const { attributes, @@ -28,6 +28,7 @@ export default function SortableTaskItem({ task, selected = false, onRefresh, pr selected={selected} onRefresh={onRefresh} providers={providers} + providersLoaded={providersLoaded} durations={durations} apps={apps} instances={instances} diff --git a/client/src/components/cos/tabs/TaskItem.jsx b/client/src/components/cos/tabs/TaskItem.jsx index 80aedb0057..46d1234db4 100644 --- a/client/src/components/cos/tabs/TaskItem.jsx +++ b/client/src/components/cos/tabs/TaskItem.jsx @@ -141,7 +141,7 @@ function getSuccessRateStyle(rate) { return { bg: 'bg-port-error/15', text: 'text-port-error', label: 'low' }; } -export default function TaskItem({ task, agent = null, isSystem, spawning = false, selected = false, onRefresh, onTaskUnblocked, providers, durations, dragHandleProps, apps, instances = null, onEditingChange }) { +export default function TaskItem({ task, agent = null, isSystem, spawning = false, selected = false, onRefresh, onTaskUnblocked, providers, providersLoaded, durations, dragHandleProps, apps, instances = null, onEditingChange }) { // System tasks are persisted in COS-TASKS.md. Every task // mutation must name that source; otherwise the API's user-queue default // searches TASKS.md and reports the system task as missing. @@ -831,6 +831,7 @@ export default function TaskItem({ task, agent = null, isSystem, spawning = fals setRelaunching(false)} diff --git a/client/src/components/cos/tabs/TasksTab.jsx b/client/src/components/cos/tabs/TasksTab.jsx index fece5c8441..fef337943d 100644 --- a/client/src/components/cos/tabs/TasksTab.jsx +++ b/client/src/components/cos/tabs/TasksTab.jsx @@ -38,7 +38,7 @@ function SectionGlyph({ status }) { return ; } -export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, onTaskUnblocked, providers, apps }) { +export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, onTaskUnblocked, providers, providersLoaded, apps }) { const [searchParams] = useSearchParams(); const [userTasksLocal, setUserTasksLocal] = useState([]); const [durations, setDurations] = useState(null); @@ -231,7 +231,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o {/* Add Task Form */} - + {/* User Tasks Sections */} {pendingUserTasksLocal.length === 0 && activeUserTasksLocal.length === 0 && blockedUserTasksLocal.length === 0 && completedUserTasksLocal.length === 0 ? ( @@ -262,7 +262,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o >
{pendingUserTasksLocal.map(task => ( - + ))}
@@ -282,7 +282,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{activeUserTasksLocal.map(task => ( - + ))}
@@ -299,7 +299,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{blockedUserTasksLocal.map(task => ( - + ))}
@@ -322,7 +322,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o {showCompletedUserTasks && (
{completedUserTasksLocal.map(task => ( - + ))}
)} @@ -355,7 +355,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{pendingSystemTasks.map(task => ( - + ))}
@@ -372,7 +372,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{activeSystemTasks.map(task => ( - + ))}
@@ -389,7 +389,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{blockedSystemTasks.map(task => ( - + ))}
@@ -412,7 +412,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o {showCompletedSystemTasks && (
{completedSystemTasks.map(task => ( - + ))}
)} diff --git a/client/src/components/cos/tabs/WorkflowTab.jsx b/client/src/components/cos/tabs/WorkflowTab.jsx index 57fe18b5fc..8e2c5e27d3 100644 --- a/client/src/components/cos/tabs/WorkflowTab.jsx +++ b/client/src/components/cos/tabs/WorkflowTab.jsx @@ -106,7 +106,7 @@ function TrackGrid({ divisions }) { // Reshapes a task node into the `config` shape PerAppOverrideList expects and // renders it. Shared by the pinned TimelineRow and the flexible-queue rows so // the config reconstruction lives in exactly one place. -function AppOverridePanel({ node, apps, providers, onUpdateOverride, onBulkToggleOverride }) { +function AppOverridePanel({ node, apps, providers, providersLoaded, onUpdateOverride, onBulkToggleOverride }) { return ( ); } -function TimelineRow({ node, occurrences, windows, timeline, hours, timezone, selected, apps, providers, expanded, onSelect, onToggleExpand, onUpdateOverride, onBulkToggleOverride }) { +function TimelineRow({ node, occurrences, windows, timeline, hours, timezone, selected, apps, providers, providersLoaded, expanded, onSelect, onToggleExpand, onUpdateOverride, onBulkToggleOverride }) { const palette = trackPalette(node); const Icon = node.kind === 'job' ? Bot : GitBranch; const divisions = hours === 168 ? 7 : 8; @@ -205,7 +206,7 @@ function TimelineRow({ node, occurrences, windows, timeline, hours, timezone, se {canExpand && expanded && (
- +
)} @@ -246,7 +247,7 @@ function NextUp({ occurrences, nodeMap, hours, timezone, onSelect }) { // `providers` is the same ChiefOfStaff-owned list ScheduleTab renders — without it // the per-app rows here degraded to raw provider ids while the Schedule tab showed // display names for the very same pin (#4783). -export default function WorkflowTab({ apps, providers }) { +export default function WorkflowTab({ apps, providers, providersLoaded }) { // Zoom window + selected track live in the URL so the open editor and view // are shareable/bookmarkable and survive reload — the same "URL is the // source of truth for what's open" convention as ScheduleTab's ?task=. @@ -414,6 +415,7 @@ export default function WorkflowTab({ apps, providers }) { selected={selectedId === node.id} apps={apps} providers={providers} + providersLoaded={providersLoaded} expanded={expandedIds.has(node.id)} onSelect={setSelectedId} onToggleExpand={toggleExpand} @@ -459,7 +461,7 @@ export default function WorkflowTab({ apps, providers }) { {model.flexible.filter(node => expandedIds.has(node.id) && node.kind === 'task' && (node.totalAppCount || 0) > 0).map(node => (
{node.label} · per-app options
- +
))} diff --git a/client/src/components/fableloom/LoomEditorialAutomation.jsx b/client/src/components/fableloom/LoomEditorialAutomation.jsx index c6e871d3df..98111b1fe1 100644 --- a/client/src/components/fableloom/LoomEditorialAutomation.jsx +++ b/client/src/components/fableloom/LoomEditorialAutomation.jsx @@ -255,6 +255,7 @@ export default function LoomEditorialAutomation({ loom, dirty, onLoomUpdate }) { label="Editorial AI route" disabled={busy || providersLoading} modelDisabled={busy || providersLoading} + loading={providersLoading} emptyProviderOption="Default (editorial stage or active provider)" emptyModelOption="Default model" alwaysShowModel={!!route.providerId} diff --git a/client/src/components/fableloom/LoomEpisodeFeedback.jsx b/client/src/components/fableloom/LoomEpisodeFeedback.jsx index d703cd1217..f528076391 100644 --- a/client/src/components/fableloom/LoomEpisodeFeedback.jsx +++ b/client/src/components/fableloom/LoomEpisodeFeedback.jsx @@ -104,6 +104,7 @@ export default function LoomEpisodeFeedback({ layout="stacked" disabled={disabled || submitting || providersLoading} modelDisabled={disabled || submitting || providersLoading} + loading={providersLoading} emptyProviderOption="Default (feedback stage or active provider)" emptyModelOption="Default model" alwaysShowModel={!!route.providerId} diff --git a/client/src/components/fableloom/LoomEpisodeOutlinePlanner.jsx b/client/src/components/fableloom/LoomEpisodeOutlinePlanner.jsx index 4d9a903706..b470a15c57 100644 --- a/client/src/components/fableloom/LoomEpisodeOutlinePlanner.jsx +++ b/client/src/components/fableloom/LoomEpisodeOutlinePlanner.jsx @@ -373,6 +373,7 @@ export default function LoomEpisodeOutlinePlanner({ layout="stacked" disabled={busy || generating || saving || validating || reviewing || expanding} modelDisabled={busy || generating || saving || validating || reviewing || expanding} + loading={providersLoading} emptyProviderOption="Default (outline stage or active provider)" emptyModelOption="Default model" alwaysShowModel={!!route.providerId} diff --git a/client/src/components/fableloom/LoomSeriesPlan.jsx b/client/src/components/fableloom/LoomSeriesPlan.jsx index ad3d172744..30103926c4 100644 --- a/client/src/components/fableloom/LoomSeriesPlan.jsx +++ b/client/src/components/fableloom/LoomSeriesPlan.jsx @@ -454,6 +454,7 @@ function SeriesAiEditor({ loom, dirty, onLoomUpdate }) { layout="stacked" disabled={busy || loading} modelDisabled={busy || loading} + loading={loading} emptyProviderOption="Default (series-plan stage or active provider)" emptyModelOption="Default model" alwaysShowModel={!!route.providerId} diff --git a/client/src/pages/ChiefOfStaff.jsx b/client/src/pages/ChiefOfStaff.jsx index 00475e572f..32f69a4426 100644 --- a/client/src/pages/ChiefOfStaff.jsx +++ b/client/src/pages/ChiefOfStaff.jsx @@ -190,6 +190,18 @@ export default function ChiefOfStaff() { return data; }, []); + // Same self-committing-read fix as `applyProviders`, for the same reason: apps + // feeds the Schedule/Tasks/Agents app pickers, so bundling it into + // `secondaryRead`'s Promise.all held it hostage to `getCosActionableInsights` + // (a server-side health check) and left those pickers showing an empty list + // for seconds. `sameJsonShape` keeps the array identity stable on an unchanged + // 30s poll payload so this doesn't cost a full-tree re-render each tick. + const applyApps = useCallback((data) => { + const filtered = (Array.isArray(data) ? data : []).filter(a => a.id !== 'portos-autofixer'); + setApps(prev => (sameJsonShape(prev, filtered) ? prev : filtered)); + return filtered; + }, []); + // Derive agent state from system status const deriveAgentState = useCallback((statusData, agentsData, healthData) => { if (!statusData?.running) return 'sleeping'; @@ -232,8 +244,10 @@ export default function ChiefOfStaff() { const providersRead = api.getProviders() .catch(() => ({ providers: [] })) .then(applyProviders); + // Same rationale as providersRead above: apps commits on its own settle + // instead of waiting on the slower siblings in secondaryRead. + const appsRead = api.getApps().catch(() => []).then(applyApps); const secondaryRead = Promise.all([ - api.getApps().catch(() => []), api.getCosLearningSummary().catch(() => null), // `silent: true` keeps transient poll blips quiet, matching the banner's // retired 60s poll; `.catch(() => null)` → preserve last-good below. @@ -264,10 +278,10 @@ export default function ChiefOfStaff() { const runningAgent = agentsData.find(a => a.status === 'running'); setActiveAgentMeta(runningAgent?.metadata || null); - const [appsData, learningSummaryData, insightsData] = await secondaryRead; - // Both self-committing reads are barriers, not values: `mergedHealth` below - // reads what `healthRead` wrote, so it must not run before they settle. - await Promise.all([healthRead, providersRead]); + const [learningSummaryData, insightsData] = await secondaryRead; + // All three self-committing reads are barriers, not values: `mergedHealth` + // below reads what `healthRead` wrote, so it must not run before they settle. + await Promise.all([healthRead, providersRead, appsRead]); // `getCosHealth` above reads the *pre-check* persisted health, while the // getCosActionableInsights call in this same batch triggers a fresh server // health check (cos.runHealthCheck) that emits `cos:health:check` — the @@ -276,8 +290,6 @@ export default function ChiefOfStaff() { // failed); everything below derives from what it returned, never from the // raw read, so the bubble can't name an older issue than the tile shows. const mergedHealth = healthRef.current; - // Filter out PortOS Autofixer (it's part of PortOS project) - setApps(appsData.filter(a => a.id !== 'portos-autofixer')); setLearningSummary(learningSummaryData); // Apply a real insights payload (including a legitimately-empty []); a null // from a failed/transient fetch preserves the last-good array so the banner @@ -1186,12 +1198,12 @@ export default function ChiefOfStaff() { {activeTab === 'tasks' && (
- +
)} {activeTab === 'agents' && (
- +
)} {activeTab === 'jobs' && ( @@ -1232,7 +1244,7 @@ export default function ChiefOfStaff() { {activeTab === 'workflow' && (
}> - +
)} diff --git a/client/src/pages/FableLoom.jsx b/client/src/pages/FableLoom.jsx index 0e9b274d51..75d1375896 100644 --- a/client/src/pages/FableLoom.jsx +++ b/client/src/pages/FableLoom.jsx @@ -234,6 +234,7 @@ export default function FableLoom() { label="Plan AI provider" disabled={creating || providersLoading} modelDisabled={creating || providersLoading} + loading={providersLoading} emptyProviderOption="Default (series-plan stage or active provider)" emptyModelOption="Default model" alwaysShowModel={!!planRoute.providerId}