diff --git a/console/src/routes/channels-catalog.ts b/console/src/routes/channels-catalog.ts
index 22333297..fc8af1a2 100644
--- a/console/src/routes/channels-catalog.ts
+++ b/console/src/routes/channels-catalog.ts
@@ -32,21 +32,21 @@ function countKeys(value: Record): number {
}
export function countDiscordGuilds(config: AdminConfig): number {
- return countKeys(config.discord.guilds);
+ return countKeys(config.discord?.guilds ?? {});
}
export function countDiscordOverrides(config: AdminConfig): number {
- return Object.values(config.discord.guilds).reduce((total, guild) => {
+ return Object.values(config.discord?.guilds ?? {}).reduce((total, guild) => {
return total + countKeys(guild.channels);
}, 0);
}
export function countTeams(config: AdminConfig): number {
- return countKeys(config.msteams.teams);
+ return countKeys(config.msteams?.teams ?? {});
}
export function countTeamsOverrides(config: AdminConfig): number {
- return Object.values(config.msteams.teams).reduce((total, team) => {
+ return Object.values(config.msteams?.teams ?? {}).reduce((total, team) => {
return total + countKeys(team.channels);
}, 0);
}
@@ -61,8 +61,9 @@ function describeDiscord(
): ChannelCatalogItem {
const guildCount = countDiscordGuilds(config);
const overrideCount = countDiscordOverrides(config);
- const enabled =
- config.discord.commandsOnly || config.discord.groupPolicy !== 'disabled';
+ const enabled = config.discord
+ ? config.discord.commandsOnly || config.discord.groupPolicy !== 'disabled'
+ : false;
const tokenConfigured = options.discordTokenConfigured === true;
const active = enabled && tokenConfigured;
const configured =
@@ -76,7 +77,9 @@ function describeDiscord(
return {
kind: 'discord',
label: 'Discord',
- summary: `${pluralize(guildCount, 'guild default')} · ${pluralize(overrideCount, 'explicit override')}`,
+ summary: config.discord
+ ? `${pluralize(guildCount, 'guild default')} · ${pluralize(overrideCount, 'explicit override')}`
+ : 'Not configured',
statusTone,
statusLabel:
statusTone === 'active'
@@ -92,12 +95,13 @@ function describeWhatsApp(
options: ChannelCatalogOptions,
): ChannelCatalogItem {
const linked = options.whatsappLinked === true;
- const enabled =
- config.whatsapp.dmPolicy !== 'disabled' ||
- config.whatsapp.groupPolicy !== 'disabled';
+ const enabled = config.whatsapp
+ ? config.whatsapp.dmPolicy !== 'disabled' ||
+ config.whatsapp.groupPolicy !== 'disabled'
+ : false;
const summary = linked
? enabled
- ? `Linked device · groups ${config.whatsapp.groupPolicy}`
+ ? `Linked device · groups ${config.whatsapp?.groupPolicy}`
: 'Linked device available but transport is off'
: enabled
? 'Link device to enable WhatsApp'
@@ -124,15 +128,16 @@ function describeTelegram(
): ChannelCatalogItem {
const tokenConfigured = options.telegramTokenConfigured === true;
const inboundEnabled =
- config.telegram.dmPolicy !== 'disabled' ||
- config.telegram.groupPolicy !== 'disabled';
- const active = config.telegram.enabled && tokenConfigured && inboundEnabled;
+ config.telegram?.dmPolicy !== 'disabled' ||
+ config.telegram?.groupPolicy !== 'disabled';
+ const active =
+ (config.telegram?.enabled ?? false) && tokenConfigured && inboundEnabled;
const configured =
active ||
- config.telegram.enabled ||
+ (config.telegram?.enabled ?? false) ||
tokenConfigured ||
- config.telegram.allowFrom.length > 0 ||
- config.telegram.groupAllowFrom.length > 0;
+ (config.telegram?.allowFrom?.length ?? 0) > 0 ||
+ (config.telegram?.groupAllowFrom?.length ?? 0) > 0;
const statusTone = active
? 'active'
: configured
@@ -142,7 +147,9 @@ function describeTelegram(
return {
kind: 'telegram',
label: 'Telegram',
- summary: `DM ${config.telegram.dmPolicy} · groups ${config.telegram.groupPolicy}`,
+ summary: config.telegram
+ ? `DM ${config.telegram.dmPolicy} · groups ${config.telegram.groupPolicy}`
+ : 'Not configured',
statusTone,
statusLabel:
statusTone === 'active'
@@ -160,14 +167,16 @@ function describeSlack(
const botTokenConfigured = options.slackBotTokenConfigured === true;
const appTokenConfigured = options.slackAppTokenConfigured === true;
const active =
- config.slack.enabled && botTokenConfigured && appTokenConfigured;
+ (config.slack?.enabled ?? false) &&
+ botTokenConfigured &&
+ appTokenConfigured;
const configured =
active ||
- config.slack.enabled ||
+ (config.slack?.enabled ?? false) ||
botTokenConfigured ||
appTokenConfigured ||
- config.slack.allowFrom.length > 0 ||
- config.slack.groupAllowFrom.length > 0;
+ (config.slack?.allowFrom?.length ?? 0) > 0 ||
+ (config.slack?.groupAllowFrom?.length ?? 0) > 0;
const statusTone = active
? 'active'
: configured
@@ -177,7 +186,9 @@ function describeSlack(
return {
kind: 'slack',
label: 'Slack',
- summary: `DM ${config.slack.dmPolicy} · channels ${config.slack.groupPolicy}`,
+ summary: config.slack
+ ? `DM ${config.slack.dmPolicy} · channels ${config.slack.groupPolicy}`
+ : 'Not configured',
statusTone,
statusLabel:
statusTone === 'active'
@@ -194,17 +205,17 @@ function describeEmail(
): ChannelCatalogItem {
const passwordConfigured = options.emailPasswordConfigured === true;
const active =
- config.email.enabled &&
+ (config.email?.enabled ?? false) &&
passwordConfigured &&
- !!config.email.address &&
- !!config.email.imapHost &&
- !!config.email.smtpHost;
+ !!config.email?.address &&
+ !!config.email?.imapHost &&
+ !!config.email?.smtpHost;
const configured =
active ||
- !!config.email.address ||
- !!config.email.imapHost ||
- !!config.email.smtpHost ||
- config.email.allowFrom.length > 0;
+ !!config.email?.address ||
+ !!config.email?.imapHost ||
+ !!config.email?.smtpHost ||
+ (config.email?.allowFrom?.length ?? 0) > 0;
const statusTone = active
? 'active'
: configured
@@ -214,7 +225,7 @@ function describeEmail(
return {
kind: 'email',
label: 'Email',
- summary: config.email.address || 'No mailbox address configured yet',
+ summary: config.email?.address || 'No mailbox address configured yet',
statusTone,
statusLabel:
statusTone === 'active'
@@ -229,14 +240,14 @@ function describeMSTeams(config: AdminConfig): ChannelCatalogItem {
const teamCount = countTeams(config);
const overrideCount = countTeamsOverrides(config);
const active =
- config.msteams.enabled &&
- !!config.msteams.appId &&
- !!config.msteams.tenantId;
+ (config.msteams?.enabled ?? false) &&
+ !!config.msteams?.appId &&
+ !!config.msteams?.tenantId;
const configured =
active ||
- config.msteams.enabled ||
- !!config.msteams.appId ||
- !!config.msteams.tenantId ||
+ (config.msteams?.enabled ?? false) ||
+ !!config.msteams?.appId ||
+ !!config.msteams?.tenantId ||
teamCount > 0 ||
overrideCount > 0;
const statusTone = active
@@ -248,7 +259,9 @@ function describeMSTeams(config: AdminConfig): ChannelCatalogItem {
return {
kind: 'msteams',
label: 'Microsoft Teams',
- summary: `${pluralize(teamCount, 'team default')} · ${pluralize(overrideCount, 'channel override')}`,
+ summary: config.msteams
+ ? `${pluralize(teamCount, 'team default')} · ${pluralize(overrideCount, 'channel override')}`
+ : 'Not configured',
statusTone,
statusLabel:
statusTone === 'active'
@@ -263,22 +276,24 @@ function describeIMessage(
config: AdminConfig,
options: ChannelCatalogOptions,
): ChannelCatalogItem {
- const isRemote = config.imessage.backend === 'bluebubbles';
+ const isRemote = config.imessage?.backend === 'bluebubbles';
const passwordConfigured = options.imessagePasswordConfigured === true;
const active = isRemote
- ? config.imessage.enabled &&
+ ? (config.imessage?.enabled ?? false) &&
passwordConfigured &&
- !!config.imessage.serverUrl &&
- !!config.imessage.webhookPath
- : config.imessage.enabled &&
- !!config.imessage.cliPath &&
- !!config.imessage.dbPath;
+ !!config.imessage?.serverUrl &&
+ !!config.imessage?.webhookPath
+ : (config.imessage?.enabled ?? false) &&
+ !!config.imessage?.cliPath &&
+ !!config.imessage?.dbPath;
const statusTone = active ? 'active' : 'available';
return {
kind: 'imessage',
label: 'iMessage',
- summary: `${isRemote ? 'Remote' : 'Local'} backend · DM ${config.imessage.dmPolicy}`,
+ summary: config.imessage
+ ? `${isRemote ? 'Remote' : 'Local'} backend · DM ${config.imessage.dmPolicy}`
+ : 'Not configured',
statusTone,
statusLabel: statusTone === 'active' ? 'active' : 'available',
};
diff --git a/console/src/routes/jobs.tsx b/console/src/routes/jobs.tsx
index 56b7a60d..b9117dd8 100644
--- a/console/src/routes/jobs.tsx
+++ b/console/src/routes/jobs.tsx
@@ -730,6 +730,14 @@ export function JobsPage() {
) : null}
+ {allItems.length === 0 ? (
+
+
Jobs are async tasks created by the agent or triggered manually.
+
+ New Job
+
+
+ ) : (
) : null}
+ )}
);
}
diff --git a/console/src/routes/mcp.tsx b/console/src/routes/mcp.tsx
index 058febfc..90bc420a 100644
--- a/console/src/routes/mcp.tsx
+++ b/console/src/routes/mcp.tsx
@@ -101,6 +101,13 @@ export function McpPage() {
const toast = useToast();
const [selectedName, setSelectedName] = useState(null);
const [draft, setDraft] = useState(createDraft());
+ const [showEditor, setShowEditor] = useState(false);
+
+ const openNewServer = () => {
+ setSelectedName(null);
+ setDraft(createDraft());
+ setShowEditor(true);
+ };
const mcpQuery = useQuery({
queryKey: ['mcp', auth.token],
@@ -129,6 +136,7 @@ export function McpPage() {
queryClient.setQueryData(['mcp', auth.token], payload);
setSelectedName(null);
setDraft(createDraft());
+ setShowEditor(false);
toast.success('MCP server deleted.');
},
onError: (error) => {
@@ -154,226 +162,262 @@ export function McpPage() {
+ New server
+
+ ) : null
+ }
+ />
+
+ {!mcpQuery.isLoading &&
+ !mcpQuery.isError &&
+ !mcpQuery.data?.servers.length &&
+ !showEditor ? (
+
+
+ MCP servers let the agent call external tools over the Model Context
+ Protocol.
+
- }
- />
-
-
-
- {mcpQuery.isLoading ? (
- Loading MCP servers...
- ) : mcpQuery.data?.servers.length ? (
-
- {mcpQuery.data.servers.map((server) => (
+
+ ) : (
+
+
+ {mcpQuery.isLoading ? (
+ Loading MCP servers...
+ ) : mcpQuery.data?.servers.length ? (
+
+ {mcpQuery.data.servers.map((server) => (
+
+ ))}
+
+ ) : (
+
+
+ MCP servers let the agent call external tools over the Model
+ Context Protocol.
+
- ))}
-
- ) : (
-
- No MCP servers are configured yet.
-
- )}
-
-
-
-
-
-
-
-
-
-
- setDraft((current) => ({
- ...current,
- enabled,
- }))
- }
- />
+
+ )}
+
- {draft.transport === 'stdio' ? (
- <>
-
+ {showEditor ? (
+
+
-
- >
- ) : (
- <>
-
-
- >
- )}
-
-
- {selectedServer ? (
-
- ) : null}
-
+
+ setDraft((current) => ({
+ ...current,
+ enabled,
+ }))
+ }
+ />
+
+ {draft.transport === 'stdio' ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
- {selectedServer ? (
-
-
Summary
-
{selectedServer.summary}
+
+
+ {selectedServer ? (
+
+ ) : null}
+
+
+ {selectedServer ? (
+
+
Summary
+
{selectedServer.summary}
+
+ ) : null}
- ) : null}
-
-
-
+
+ ) : null}
+
+ )}
);
}
diff --git a/console/src/routes/plugins.tsx b/console/src/routes/plugins.tsx
index 41ece876..224d14b8 100644
--- a/console/src/routes/plugins.tsx
+++ b/console/src/routes/plugins.tsx
@@ -152,6 +152,21 @@ export function PluginsPage() {
>
{pluginsQuery.isLoading ? (
Loading plugins...
+ ) : pluginsQuery.data?.plugins.length === 0 ? (
+
) : plugins.length === 0 ? (
No plugins match this filter.
) : (
diff --git a/console/src/routes/scheduler.tsx b/console/src/routes/scheduler.tsx
index cbcb79d6..81188a2c 100644
--- a/console/src/routes/scheduler.tsx
+++ b/console/src/routes/scheduler.tsx
@@ -1018,11 +1018,13 @@ export function SchedulerPage() {
const toast = useToast();
const queryClient = useQueryClient();
const [selectedId, setSelectedId] = useState(() => {
- const requestedId =
- new URLSearchParams(window.location.search).get('jobId') || '';
- return requestedId.trim() || null;
+ const requestedId = new URLSearchParams(window.location.search).get('jobId')?.trim() ?? '';
+ return requestedId || null;
});
const [draft, setDraft] = useState(createDraft());
+ const [showEditor, setShowEditor] = useState(() => {
+ return Boolean(new URLSearchParams(window.location.search).get('jobId')?.trim());
+ });
const schedulerQuery = useQuery({
queryKey: ['scheduler', auth.token],
@@ -1149,6 +1151,12 @@ export function SchedulerPage() {
window.history.replaceState({}, '', currentUrl.toString());
}, [selectedId]);
+ const openNewJob = () => {
+ setSelectedId(null);
+ setDraft(createDraft());
+ setShowEditor(true);
+ };
+
return (
{
- setSelectedId(null);
- setDraft(createDraft());
- }}
+ onClick={openNewJob}
>
New job
@@ -1185,7 +1190,10 @@ export function SchedulerPage() {
: 'selectable-row'
}
type="button"
- onClick={() => setSelectedId(job.id)}
+ onClick={() => {
+ setSelectedId(job.id);
+ setShowEditor(true);
+ }}
>
{job.name}
@@ -1203,54 +1211,69 @@ export function SchedulerPage() {
))}
) : (
- No scheduled work yet.
+
+
+ Scheduled jobs let you run agent tasks automatically on a cron
+ schedule.
+
+
+
)}
- {isTaskJob(selectedJob) ? (
-
- pauseMutation.mutate(selectedJob.disabled ? 'resume' : 'pause')
- }
- onDelete={() => deleteMutation.mutate()}
- />
- ) : (
- setDraft((current) => update(current))}
- onSave={() => {
- const nextDraft = prepareDraftForSave(
- applyResolvedTarget(draft, targetControl),
- );
- setDraft(nextDraft);
- saveMutation.mutate(nextDraft);
- }}
- onCancel={() => {
- if (selectedConfigJob) {
- setDraft(createDraft(selectedConfigJob));
- return;
+ {showEditor ? (
+ isTaskJob(selectedJob) ? (
+
+ pauseMutation.mutate(selectedJob.disabled ? 'resume' : 'pause')
}
- setSelectedId(null);
- setDraft(createDraft());
- window.location.href = '/admin/jobs';
- }}
- onPauseToggle={() =>
- pauseMutation.mutate(
- selectedConfigJob?.disabled ? 'resume' : 'pause',
- )
- }
- onDelete={() => deleteMutation.mutate()}
- />
- )}
+ onDelete={() => deleteMutation.mutate()}
+ />
+ ) : (
+ setDraft((current) => update(current))}
+ onSave={() => {
+ const nextDraft = prepareDraftForSave(
+ applyResolvedTarget(draft, targetControl),
+ );
+ setDraft(nextDraft);
+ saveMutation.mutate(nextDraft);
+ }}
+ onCancel={() => {
+ if (selectedConfigJob) {
+ setDraft(createDraft(selectedConfigJob));
+ return;
+ }
+ setSelectedId(null);
+ setDraft(createDraft());
+ setShowEditor(false);
+ window.location.href = '/admin/jobs';
+ }}
+ onPauseToggle={() =>
+ pauseMutation.mutate(
+ selectedConfigJob?.disabled ? 'resume' : 'pause',
+ )
+ }
+ onDelete={() => deleteMutation.mutate()}
+ />
+ )
+ ) : null}
);
diff --git a/console/src/styles.css b/console/src/styles.css
index 98345d09..4d004398 100644
--- a/console/src/styles.css
+++ b/console/src/styles.css
@@ -1882,6 +1882,39 @@ th {
background: var(--jobs-empty);
}
+.page-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ padding: 64px 24px;
+ text-align: center;
+ color: var(--muted-foreground);
+}
+
+.page-empty p {
+ margin: 0;
+ max-width: 300px;
+}
+
+.empty-state-cta {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 32px 24px;
+ text-align: center;
+ color: var(--muted-foreground);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--line);
+ background: var(--panel-muted);
+}
+
+.empty-state-cta p {
+ margin: 0;
+ max-width: 280px;
+}
+
.jobs-card {
display: grid;
gap: 8px;