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}