Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Alert, Box, Container, CssBaseline } from '@mui/material';
import { createTheme, styled, ThemeProvider } from '@mui/material/styles';
import { useDispatch, useSelector } from 'react-redux';
import { setSection, setChartRange, toggleMode } from './uiSlice';
import { setSection, setChartRange, toggleMode, setSelectedAgentId } from './uiSlice';
import {
useGetSessionsQuery,
useGetFoxmemoryOverviewQuery,
Expand All @@ -20,6 +20,7 @@ import FoxMemorySection from './components/FoxMemorySection/FoxMemorySection';
import ModelConfigSection from './components/ModelConfigSection/ModelConfigSection';
import LoginView from './components/LoginView/LoginView';
import MfaView from './components/MfaView/MfaView';
import AgentSelector from './components/AgentSelector/AgentSelector';
import SeatedFoxIcon from './components/shared/SeatedFoxIcon';

const AppShell = styled(Box, { shouldForwardProp: (p) => p !== 'mode' })<{ mode: 'light' | 'dark' }>(({ mode }) => ({
Expand Down Expand Up @@ -63,7 +64,7 @@ const isActive = (s: OpenclawSession) => (s.ageMs ?? Infinity) < MS_12H;

const App = () => {
const dispatch = useDispatch();
const { mode, section, chartRange } = useSelector((s: RootState) => s.ui);
const { mode, section, chartRange, selectedAgentId } = useSelector((s: RootState) => s.ui);
const [notice, setNotice] = useState<Notice | null>(null);
const [authState, setAuthState] = useState<AuthState>('loading');
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
Expand Down Expand Up @@ -104,7 +105,7 @@ const App = () => {
error: foxError,
refetch: refetchFox,
fulfilledTimeStamp: foxFulfilledAt,
} = useGetFoxmemoryOverviewQuery(undefined, { skip: section !== 'foxmemory', pollingInterval: 15000, skipPollingIfUnfocused: true, refetchOnFocus: true, refetchOnReconnect: true });
} = useGetFoxmemoryOverviewQuery(selectedAgentId ?? undefined, { skip: section !== 'foxmemory', pollingInterval: 15000, skipPollingIfUnfocused: true, refetchOnFocus: true, refetchOnReconnect: true });

const {
data: cronsData,
Expand Down Expand Up @@ -250,7 +251,9 @@ const App = () => {
onLogout={() => { window.location.href = '/auth/logout'; }}
/>
<Box sx={{ display: 'flex', flexGrow: 1 }}>
<Sidebar section={section} onSectionChange={(s) => dispatch(setSection(s))} health={healthMap} />
<Sidebar section={section} onSectionChange={(s) => dispatch(setSection(s))} health={healthMap}>
<AgentSelector selectedAgentId={selectedAgentId} onAgentChange={(id) => dispatch(setSelectedAgentId(id))} />
</Sidebar>
<Box sx={{ flexGrow: 1 }}>
<Container maxWidth="xl" sx={{ py: 3 }}>
{notice && <Alert severity={notice.severity} sx={{ mb: 2 }} onClose={() => setNotice(null)}>{notice.text}</Alert>}
Expand All @@ -267,13 +270,14 @@ const App = () => {
onDelete={onDelete}
/>
) : section === 'config' ? (
<ModelConfigSection />
<ModelConfigSection agentId={selectedAgentId} />
) : (
<FoxMemorySection
foxmemory={foxmemory}
chartRange={chartRange}
onChartRangeChange={(r) => dispatch(setChartRange(r))}
tabHealth={{ performance: foxIsError ? 'error' : foxmemory ? 'ok' : undefined }}
agentId={selectedAgentId}
/>
)}
</Container>
Expand Down
72 changes: 72 additions & 0 deletions web/src/components/AgentSelector/AgentSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { CircularProgress, MenuItem, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import Select from '@mui/material/Select';
import { useGetTenantsQuery, useGetAgentsQuery } from '../../services/dashboardApi';
import type { AgentSelectorProps } from './AgentSelector.types';

const CompactSelect = styled(Select)(({ theme }) => ({
fontSize: 12,
fontWeight: 600,
borderRadius: 8,
color: theme.palette.mode === 'dark' ? '#67748e' : 'rgba(255,255,255,0.85)',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.18)',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.24)' : 'rgba(255,255,255,0.35)',
},
'& .MuiSvgIcon-root': {
color: theme.palette.mode === 'dark' ? '#67748e' : 'rgba(255,255,255,0.6)',
fontSize: 18,
},
'& .MuiSelect-select': {
py: '6px',
px: '12px',
},
}));

const NONE_VALUE = '__none__';

const AgentSelector = ({ selectedAgentId, onAgentChange }: AgentSelectorProps) => {
const { data: tenantsData, isLoading: tenantsLoading } = useGetTenantsQuery();
const firstTenantId = tenantsData?.data?.[0]?.id;
const { data: agentsData, isLoading: agentsLoading } = useGetAgentsQuery(firstTenantId!, {
skip: !firstTenantId,
});

const loading = tenantsLoading || agentsLoading;
const agents = agentsData?.data ?? [];

if (loading) {
return <CircularProgress size={16} sx={{ mx: 'auto', display: 'block', my: 1 }} />;
}

if (agents.length === 0) {
return (
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear stale agent selection when no agents are returned

When agents.length === 0, this branch replaces the selector with static text and never calls onAgentChange(null). Since selectedAgentId is persisted and used to scope FoxMemory requests, a previously selected (now deleted/inaccessible) agent can leave the app permanently querying with an invalid agentId, and the user has no UI path back to Default (all) because the dropdown is hidden in this state.

Useful? React with 👍 / 👎.

<Typography variant="caption" sx={{ fontSize: 11, opacity: 0.5, px: 1.5, py: 1, display: 'block' }}>
Default agent
</Typography>
);
}

return (
<CompactSelect
size="small"
fullWidth
value={selectedAgentId ?? NONE_VALUE}
onChange={(e) => {
const val = e.target.value as string;
onAgentChange(val === NONE_VALUE ? null : val);
}}
>
<MenuItem value={NONE_VALUE} sx={{ fontSize: 12 }}>Default (all)</MenuItem>
{agents.map((agent) => (
<MenuItem key={agent.id} value={agent.id} sx={{ fontSize: 12 }}>
{agent.name}
</MenuItem>
))}
</CompactSelect>
);
};

export default AgentSelector;
4 changes: 4 additions & 0 deletions web/src/components/AgentSelector/AgentSelector.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface AgentSelectorProps {
selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export interface FoxMemoryAgentsViewProps {
onSaveExtractionPrompt: (prompt: string | null) => Promise<void>;
onSaveUpdatePrompt: (prompt: string | null) => Promise<void>;
onSaveGraphPrompt: (prompt: string | null) => Promise<void>;
agentId?: string | null;
}
15 changes: 8 additions & 7 deletions web/src/components/FoxMemoryGraphView/FoxMemoryGraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ const MIN_DEGREE_OPTIONS = [

type PanelTab = 'connections' | 'details';

const GraphExplorer = ({ minDegree }: { minDegree: number }) => {
const { data, isLoading } = useGetFoxmemoryGraphDataQuery();
const GraphExplorer = ({ minDegree, agentId }: { minDegree: number; agentId?: string | null }) => {
const effectiveAgentId = agentId ?? undefined;
const { data, isLoading } = useGetFoxmemoryGraphDataQuery(effectiveAgentId);
const [searchGraph, { data: richData, isLoading: richLoading, reset: resetRich }] = useSearchFoxmemoryGraphMutation();
const [searchMemories, { data: memoriesData, isLoading: memoriesLoading, reset: resetMemories }] = useSearchFoxmemoryMemoriesMutation();
const [selectedNode, setSelectedNode] = useState<FoxmemoryGraphNode | null>(null);
Comment on lines +97 to 101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset graph detail state when switching agents

This component now changes graph queries by agentId, but it keeps local selection/search state across agent switches. If a user changes agents while a node is selected, the details panel can continue showing richData/memory results from the previous agent until manually cleared, which mixes data scopes and presents stale relationships for the new graph context.

Useful? React with 👍 / 👎.

Expand Down Expand Up @@ -161,12 +162,12 @@ const GraphExplorer = ({ minDegree }: { minDegree: number }) => {
const n = node as RawNode;
setSelectedNode((prev) => {
if (prev?.id === n.id) { resetRich(); resetMemories(); return null; }
searchGraph({ query: n.name });
searchMemories({ query: n.name, top_k: 8 });
searchGraph({ query: n.name, agentId: effectiveAgentId });
searchMemories({ query: n.name, top_k: 8, agentId: effectiveAgentId });
setPanelTab('connections');
return { id: n.id, name: n.name, degree: n.degree };
});
}, [searchGraph, resetRich, searchMemories, resetMemories]);
}, [searchGraph, resetRich, searchMemories, resetMemories, effectiveAgentId]);

const nodeColor = useCallback((node: object) => {
const n = node as RawNode;
Expand Down Expand Up @@ -373,7 +374,7 @@ const GraphExplorer = ({ minDegree }: { minDegree: number }) => {

// ── Main component ────────────────────────────────────────────────────────────

const FoxMemoryGraphView = ({ stats, diagnostics, loading }: FoxMemoryGraphViewProps) => {
const FoxMemoryGraphView = ({ stats, diagnostics, loading, agentId }: FoxMemoryGraphViewProps) => {
const [subView, setSubView] = useState<GraphSubView>('performance');
const [minDegree, setMinDegree] = useState(1);

Expand Down Expand Up @@ -461,7 +462,7 @@ const FoxMemoryGraphView = ({ stats, diagnostics, loading }: FoxMemoryGraphViewP


{subView === 'explorer' ? (
<GraphExplorer minDegree={minDegree} />
<GraphExplorer minDegree={minDegree} agentId={agentId} />
) : (
<>
<Grid container spacing={1.5} mb={2.5}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export interface FoxMemoryGraphViewProps {
stats: FoxmemoryGraphStats | undefined;
diagnostics: FoxmemoryDiagnostics | null | undefined;
loading: boolean;
agentId?: string | null;
}
14 changes: 8 additions & 6 deletions web/src/components/FoxMemorySection/FoxMemorySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const TabLabel = ({ label, health }: { label: string; health?: import('../../typ
</Box>
);

const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth }: FoxMemorySectionProps) => {
const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth, agentId }: FoxMemorySectionProps) => {
const [subView, setSubView] = useState<SubView>('performance');
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
const toggleRow = (i: number) => setExpandedRows((prev) => {
Expand All @@ -62,10 +62,11 @@ const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth
return next;
});

const { data: prompts, isFetching: promptsLoading } = useGetFoxmemoryPromptsQuery(undefined, {
const effectiveAgentId = agentId ?? undefined;
const { data: prompts, isFetching: promptsLoading } = useGetFoxmemoryPromptsQuery(effectiveAgentId, {
skip: subView !== 'agents',
});
const { data: graphStatsData, isFetching: graphStatsLoading } = useGetFoxmemoryGraphStatsQuery(undefined, {
const { data: graphStatsData, isFetching: graphStatsLoading } = useGetFoxmemoryGraphStatsQuery(effectiveAgentId, {
skip: subView !== 'graph',
pollingInterval: 15000,
skipPollingIfUnfocused: true,
Expand All @@ -74,9 +75,9 @@ const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth
const [saveUpdatePrompt] = useSetFoxmemoryUpdatePromptMutation();
const [saveGraphPrompt] = useSetFoxmemoryGraphPromptMutation();

const onSaveExtractionPrompt = async (prompt: string | null) => { await saveExtractionPrompt({ prompt }).unwrap(); };
const onSaveUpdatePrompt = async (prompt: string | null) => { await saveUpdatePrompt({ prompt }).unwrap(); };
const onSaveGraphPrompt = async (prompt: string | null) => { await saveGraphPrompt({ prompt }).unwrap(); };
const onSaveExtractionPrompt = async (prompt: string | null) => { await saveExtractionPrompt({ prompt, agentId: effectiveAgentId }).unwrap(); };
const onSaveUpdatePrompt = async (prompt: string | null) => { await saveUpdatePrompt({ prompt, agentId: effectiveAgentId }).unwrap(); };
const onSaveGraphPrompt = async (prompt: string | null) => { await saveGraphPrompt({ prompt, agentId: effectiveAgentId }).unwrap(); };

const chartData = useMemo(() => {
if (!foxmemory) return [];
Expand Down Expand Up @@ -111,6 +112,7 @@ const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth
stats={graphStatsData?.data}
diagnostics={foxmemory?.diagnostics}
loading={graphStatsLoading}
agentId={agentId}
/>
) : subView === 'agents' ? (
<FoxMemoryAgentsView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export interface FoxMemorySectionProps {
chartRange: ChartRange;
onChartRangeChange: (range: ChartRange) => void;
tabHealth?: { performance?: SectionHealth; agents?: SectionHealth; graph?: SectionHealth };
agentId?: string | null;
}
18 changes: 10 additions & 8 deletions web/src/components/ModelConfigSection/ModelConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import { useGetFoxmemoryModelsQuery, useGetFoxmemoryCatalogQuery, useSetFoxmemoryModelMutation, useRevertFoxmemoryModelMutation, useAddCatalogModelMutation, useUpdateCatalogModelMutation, useDeleteCatalogModelMutation } from '../../services/dashboardApi';
import type { CatalogModel, ModelRoleKey } from '../../types';
import type { ModelConfigSectionProps } from './ModelConfigSection.types';

const fmtCost = (v?: number | null) => v == null ? '—' : `$${v.toFixed(3)}`;

Expand Down Expand Up @@ -253,9 +254,10 @@ const CatalogForm = ({ initial, isNew, saving, onSave, onCancel }: CatalogFormPr
);
};

const ModelConfigSection = () => {
const { data: modelsData, isLoading: modelsLoading } = useGetFoxmemoryModelsQuery();
const { data: catalogData, isLoading: catalogLoading } = useGetFoxmemoryCatalogQuery();
const ModelConfigSection = ({ agentId }: ModelConfigSectionProps) => {
const effectiveAgentId = agentId ?? undefined;
const { data: modelsData, isLoading: modelsLoading } = useGetFoxmemoryModelsQuery(effectiveAgentId);
const { data: catalogData, isLoading: catalogLoading } = useGetFoxmemoryCatalogQuery(effectiveAgentId);
const [setModel] = useSetFoxmemoryModelMutation();
const [revertModel] = useRevertFoxmemoryModelMutation();
const [addCatalog] = useAddCatalogModelMutation();
Expand All @@ -270,7 +272,7 @@ const ModelConfigSection = () => {
const handleApply = async (key: ModelRoleKey, value: string) => {
setSavingKey(key);
try {
await setModel({ key, value }).unwrap();
await setModel({ key, value, agentId: effectiveAgentId }).unwrap();
} finally {
setSavingKey(null);
}
Expand All @@ -279,7 +281,7 @@ const ModelConfigSection = () => {
const handleRevert = async (key: ModelRoleKey) => {
setSavingKey(key);
try {
await revertModel(key).unwrap();
await revertModel({ key, agentId: effectiveAgentId }).unwrap();
} finally {
setSavingKey(null);
}
Expand All @@ -297,19 +299,19 @@ const ModelConfigSection = () => {

const handleAdd = async (form: CatalogFormData) => {
setCatalogSaving(true);
try { await addCatalog(formToCatalog(form) as CatalogModel).unwrap(); setAddingNew(false); }
try { await addCatalog({ ...formToCatalog(form), agentId: effectiveAgentId } as CatalogModel & { agentId?: string }).unwrap(); setAddingNew(false); }
finally { setCatalogSaving(false); }
};

const handleUpdate = async (form: CatalogFormData) => {
setCatalogSaving(true);
try { await updateCatalog(formToCatalog(form) as CatalogModel).unwrap(); setEditingId(null); }
try { await updateCatalog({ ...formToCatalog(form), agentId: effectiveAgentId } as CatalogModel & { agentId?: string }).unwrap(); setEditingId(null); }
finally { setCatalogSaving(false); }
};

const handleDelete = async (id: string) => {
setCatalogSaving(true);
try { await deleteCatalog(id).unwrap(); setDeletingId(null); }
try { await deleteCatalog({ id, agentId: effectiveAgentId }).unwrap(); setDeletingId(null); }
finally { setCatalogSaving(false); }
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export interface ModelConfigSectionProps {}
export interface ModelConfigSectionProps {
agentId?: string | null;
}
21 changes: 20 additions & 1 deletion web/src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const NAV_ITEMS = [
{ section: 'config' as const, label: 'Model Config', icon: <TuneRoundedIcon fontSize="small" /> },
];

const Sidebar = ({ section, onSectionChange, health }: SidebarProps) => (
const Sidebar = ({ section, onSectionChange, health, children }: SidebarProps) => (
<StyledDrawer variant="permanent">
<SidebarPanel>
<List disablePadding sx={{ display: 'grid' }}>
Expand All @@ -115,6 +115,25 @@ const Sidebar = ({ section, onSectionChange, health }: SidebarProps) => (
</NavItem>
))}
</List>
{children && (
<Box px={0.5} mt={1.5}>
<Typography
variant="caption"
sx={{
color: (theme) => theme.palette.mode === 'dark' ? '#adb5bd' : 'rgba(255,255,255,0.5)',
fontSize: 10,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 0.5,
display: 'block',
mb: 0.75,
}}
>
Agent
</Typography>
{children}
</Box>
)}
<Box mt="auto" px={0.5}>
<Typography
variant="caption"
Expand Down
1 change: 1 addition & 0 deletions web/src/components/Sidebar/Sidebar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export interface SidebarProps {
section: Section;
onSectionChange: (section: Section) => void;
health?: HealthMap;
children?: React.ReactNode;
}
Loading