+
+
+
+ {agent && (
+
+
{agent.name}
+
+ {agent.last_used_at
+ ? t('agents.logs.lastUsedAt') +
+ ' ' +
+ formatDateTime(agent.last_used_at)
+ : t('agents.logs.noUsageHistory')}
+
+
+ )}
- ) : (
- agent && (
-
-
-
- {t('agents.schedules.heading')}
-
-
- {t('agents.schedules.newRecurring')}
-
-
-
-
- {t('agents.schedules.recurring')} ({recurring.length})
-
- {recurring.length === 0 ? (
-
- {t('agents.schedules.noRecurring')}
-
- ) : (
-
- {recurring.map((schedule) => (
-
-
-
-
-
- {schedule.name ||
- schedule.instruction.slice(0, 80)}
-
-
-
-
- {formatCron(schedule.cron)} · tz:{' '}
- {schedule.timezone} · next:{' '}
- {formatTimestamp(schedule.next_run_at)}
-
-
-
- openEdit(schedule)}
- className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
- >
- {t('agents.schedules.edit')}
-
-
- dispatch(
- setSchedulePaused({
- id: schedule.id,
- action:
- schedule.status === 'active'
- ? 'pause'
- : 'resume',
- token,
- }),
- )
- }
- className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
- >
- {schedule.status === 'active'
- ? t('agents.schedules.pause')
- : t('agents.schedules.resume')}
-
-
- dispatch(
- runScheduleNow({ id: schedule.id, token }),
- )
- }
- className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
- >
- {t('agents.schedules.runNow')}
-
- requestDelete(schedule)}
- className="rounded-full px-5"
- >
- {t('agents.schedules.delete')}
-
-
-
-
- setExpanded(
- expanded === schedule.id ? null : schedule.id,
- )
- }
- className="mt-2 h-auto p-0 text-xs underline"
+ {loadingAgent ? (
+
+
+
+ ) : (
+ agent && (
+
+
+
+ {t('agents.schedules.heading')}
+
+
+ {t('agents.schedules.newRecurring')}
+
+
+
+
+ {t('agents.schedules.recurring')} ({recurring.length})
+
+ {recurring.length === 0 ? (
+
+ {t('agents.schedules.noRecurring')}
+
+ ) : (
+
- )}
-
-
-
- {t('agents.schedules.oneTime')} ({oneTime.length})
-
- {oneTime.length === 0 ? (
-
- {t('agents.schedules.noOneTime')}
-
- ) : (
-
- {oneTime.map((schedule) => (
-
-
-
-
-
- {schedule.name ||
- schedule.instruction.slice(0, 80)}
+
+
+
+
+ {schedule.name ||
+ schedule.instruction.slice(0, 80)}
+
+
+
+
+ {formatCron(schedule.cron)} · tz:{' '}
+ {schedule.timezone} · next:{' '}
+ {formatTimestamp(schedule.next_run_at)}
-
-
- runs at {formatTimestamp(schedule.run_at)}
-
-
-
- {schedule.status === 'active' && (
+
{t('agents.schedules.edit')}
- )}
- {schedule.status === 'active' && (
+
+ dispatch(
+ setSchedulePaused({
+ id: schedule.id,
+ action:
+ schedule.status === 'active'
+ ? 'pause'
+ : 'resume',
+ token,
+ }),
+ )
+ }
+ className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
+ >
+ {schedule.status === 'active'
+ ? t('agents.schedules.pause')
+ : t('agents.schedules.resume')}
+
+
+ dispatch(
+ runScheduleNow({ id: schedule.id, token }),
+ )
+ }
+ className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
+ >
+ {t('agents.schedules.runNow')}
+
requestDelete(schedule)}
className="rounded-full px-5"
>
- {t('agents.schedules.cancel')}
+ {t('agents.schedules.delete')}
- )}
+
-
-
- ))}
-
- )}
-
-
setActiveRun(null)}
- />
- {modalOpen && (
-
+ setExpanded(
+ expanded === schedule.id ? null : schedule.id,
+ )
+ }
+ className="mt-2 h-auto p-0 text-xs underline"
+ >
+ {expanded === schedule.id
+ ? t('agents.schedules.hideRuns')
+ : t('agents.schedules.showRuns')}
+
+ {expanded === schedule.id && (
+
+ setActiveRun(run)}
+ />
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {t('agents.schedules.oneTime')} ({oneTime.length})
+
+ {oneTime.length === 0 ? (
+
+ {t('agents.schedules.noOneTime')}
+
+ ) : (
+
+ {oneTime.map((schedule) => (
+
+
+
+
+
+ {schedule.name ||
+ schedule.instruction.slice(0, 80)}
+
+
+
+
+ runs at {formatTimestamp(schedule.run_at)}
+
+
+
+ {schedule.status === 'active' && (
+ openEdit(schedule)}
+ className="border-primary text-primary hover:bg-primary/90 rounded-full border border-solid bg-transparent px-5 hover:text-white"
+ >
+ {t('agents.schedules.edit')}
+
+ )}
+ {schedule.status === 'active' && (
+ requestDelete(schedule)}
+ className="rounded-full px-5"
+ >
+ {t('agents.schedules.cancel')}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ setActiveRun(null)}
/>
- )}
- setScheduleToDelete(null)}
- variant="danger"
- />
-
- )
- )}
+ {modalOpen && (
+
+ )}
+
setScheduleToDelete(null)}
+ variant="danger"
+ />
+
+ )
+ )}
+
);
}
diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx
index d71867f6..a31bbf50 100644
--- a/frontend/src/agents/workflow/WorkflowBuilder.tsx
+++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx
@@ -65,6 +65,7 @@ import {
selectToken,
} from '../../preferences/preferenceSlice';
import { getToolDisplayName } from '../../utils/toolUtils';
+import { agentEditPath, agentsListPath } from '../paths';
import AgentPageHeader from '../AgentPageHeader';
import { Agent } from '../types';
import { ConditionCase, WorkflowNode } from '../types/workflow';
@@ -669,7 +670,7 @@ function WorkflowBuilderInner() {
}, []);
const navigateBackToAgents = useCallback(() => {
- navigate(folderId ? `/agents?folder=${folderId}` : '/agents');
+ navigate(agentsListPath(folderId));
}, [navigate, folderId]);
const handleDeleteAgent = useCallback(async () => {
@@ -1548,7 +1549,7 @@ function WorkflowBuilderInner() {
diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx
index 1cb2487e..592509f8 100644
--- a/frontend/src/conversation/Conversation.tsx
+++ b/frontend/src/conversation/Conversation.tsx
@@ -9,6 +9,7 @@ import { Agent } from '../agents/types';
import ArtifactSidebar from '../components/ArtifactSidebar';
import ErrorBoundary from '../components/ErrorBoundary';
import MessageInput from '../components/MessageInput';
+import { agentChatPath, agentEditPathFor } from '../agents/paths';
import { useMediaQuery } from '../hooks';
import {
selectConversationId,
@@ -122,7 +123,7 @@ export default function Conversation() {
useEffect(() => {
if (!isNewChatRoute || !conversationId) return;
const target = urlAgentId
- ? `/agents/${urlAgentId}/c/${conversationId}`
+ ? agentChatPath(urlAgentId, conversationId)
: `/c/${conversationId}`;
navigate(target, { replace: true });
}, [conversationId, isNewChatRoute, urlAgentId]);
@@ -397,12 +398,7 @@ export default function Conversation() {
agent={selectedAgent}
onEdit={
selectedAgent.id
- ? () =>
- navigate(
- selectedAgent.agent_type === 'workflow'
- ? `/agents/workflow/edit/${selectedAgent.id}`
- : `/agents/edit/${selectedAgent.id}`,
- )
+ ? () => navigate(agentEditPathFor(selectedAgent))
: undefined
}
/>
diff --git a/frontend/src/index.css b/frontend/src/index.css
index d3bd0ffc..a9e6438d 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -690,8 +690,11 @@ Avoid over-scrolling in mobile browsers
font-family: var(--font-mono);
}
+ /* Disclosure content, e.g. an expanded tool call. Short on purpose: the
+ panel's height appears at once, so a long fade reads as the content
+ lagging behind the layout rather than as a reveal. */
.fade-in {
- animation: fadeIn 0.5s ease-in-out;
+ animation: fadeIn 0.16s ease-out;
}
@keyframes fadeIn {
@@ -703,10 +706,13 @@ Avoid over-scrolling in mobile browsers
}
}
+ /* The start state lives in the keyframes, not here: with `opacity: 0` on
+ the element itself the content was only ever visible *because* an
+ animation had run to completion, so anything that stopped it running —
+ a cancelled animation, a reduced-motion reset — left the answer blank.
+ The element now rests visible and the animation is purely additive. */
.fade-in-bubble {
- opacity: 0;
- transform: translateY(10px);
- animation: fadeInUp 0.5s forwards;
+ animation: fadeInUp 0.26s ease-out;
}
.shimmer-text {
@@ -754,9 +760,19 @@ Avoid over-scrolling in mobile browsers
.dark .shimmer-text {
background-image: none;
}
+
+ /* Safe to drop outright now that both rest in their final state. */
+ .fade-in,
+ .fade-in-bubble {
+ animation: none;
+ }
}
@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
to {
opacity: 1;
transform: translateY(0);
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index df6dabfa..edcc0189 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -436,7 +436,6 @@
"label": "Teams",
"subtitle": "Verwalte deine Teams, Mitglieder und geteilten Ressourcen.",
"newTeam": "Neues Team",
- "backToTeams": "Zurück zu den Teams",
"createTeam": "Team erstellen",
"createTeamDescription": "Gib deinem Team einen Namen. Mitglieder und Ressourcen kannst du nach dem Erstellen hinzufügen.",
"editTeam": "Bearbeiten",
@@ -565,7 +564,6 @@
"queryParameters": "Abfrageparameter",
"body": "Body",
"deleteActionWarning": "Bist du sicher, dass du die Aktion \"{{name}}\" löschen möchtest?",
- "backToAllTools": "Zurück zu allen Werkzeugen",
"save": "Speichern",
"saving": "Speichere...",
"saveFailed": "Werkzeug-Konfiguration konnte nicht gespeichert werden",
@@ -872,10 +870,7 @@
"subtitle": "Das neue Geheimnis für \"{{name}}\" ist bereit. Das alte funktioniert nicht mehr."
}
}
- },
- "scrollTabsLeft": "Tabs nach links scrollen",
- "tabsAriaLabel": "Einstellungs-Tabs",
- "scrollTabsRight": "Tabs nach rechts scrollen"
+ }
},
"modals": {
"uploadDoc": {
@@ -1647,7 +1642,17 @@
"increaseZoom": "Vergrößern"
},
"navigation": {
- "agents": "Agenten"
+ "agents": "Agenten",
+ "backToApp": "Zurück zur App",
+ "backToAgents": "Zurück zu den Agenten",
+ "sections": {
+ "groups": {
+ "personal": "Persönlich",
+ "workspace": "Arbeitsbereich",
+ "insights": "Auswertungen",
+ "administration": "Administration"
+ }
+ }
},
"teams": {
"switcher": {
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index cdf03a26..577d2213 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -441,7 +441,6 @@
"label": "Teams",
"subtitle": "Manage your teams, members, and shared resources.",
"newTeam": "New team",
- "backToTeams": "Back to teams",
"createTeam": "Create a team",
"createTeamDescription": "Give your team a name. You can add members and share resources once it's created.",
"editTeam": "Edit",
@@ -570,7 +569,6 @@
"queryParameters": "Query Parameters",
"body": "Body",
"deleteActionWarning": "Are you sure you want to delete the action \"{{name}}\"?",
- "backToAllTools": "Back to all tools",
"save": "Save",
"saving": "Saving...",
"saveFailed": "Failed to save tool configuration",
@@ -877,10 +875,7 @@
"subtitle": "The new secret for \"{{name}}\" is ready. The old one no longer works."
}
}
- },
- "scrollTabsLeft": "Scroll tabs left",
- "tabsAriaLabel": "Settings tabs",
- "scrollTabsRight": "Scroll tabs right"
+ }
},
"modals": {
"uploadDoc": {
@@ -1658,7 +1653,17 @@
"increaseZoom": "Increase zoom"
},
"navigation": {
- "agents": "Agents"
+ "agents": "Agents",
+ "backToApp": "Back to app",
+ "backToAgents": "Back to agents",
+ "sections": {
+ "groups": {
+ "personal": "Personal",
+ "workspace": "Workspace",
+ "insights": "Insights",
+ "administration": "Administration"
+ }
+ }
},
"teams": {
"switcher": {
@@ -1677,5 +1682,16 @@
},
"prompts": {
"textAriaLabel": "Prompt Text"
+ },
+ "admin": {
+ "label": "Admin",
+ "tabs": {
+ "overview": "Overview",
+ "users": "Users",
+ "admins": "Admins",
+ "usage": "Usage",
+ "quotas": "Quotas",
+ "audit": "Audit"
+ }
}
}
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index 76058628..622667bb 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -436,7 +436,6 @@
"label": "Equipos",
"subtitle": "Gestiona tus equipos, miembros y recursos compartidos.",
"newTeam": "Nuevo equipo",
- "backToTeams": "Volver a equipos",
"createTeam": "Crear un equipo",
"createTeamDescription": "Dale un nombre a tu equipo. Podrás añadir miembros y compartir recursos una vez creado.",
"editTeam": "Editar",
@@ -565,7 +564,6 @@
"queryParameters": "Parámetros de Consulta",
"body": "Cuerpo",
"deleteActionWarning": "¿Estás seguro de que deseas eliminar la acción \"{{name}}\"?",
- "backToAllTools": "Volver a todas las herramientas",
"save": "Guardar",
"saving": "Guardando...",
"saveFailed": "No se pudo guardar la configuración de la herramienta",
@@ -872,10 +870,7 @@
"subtitle": "El nuevo secreto de \"{{name}}\" está listo. El anterior ya no funciona."
}
}
- },
- "scrollTabsLeft": "Desplazar pestañas a la izquierda",
- "tabsAriaLabel": "Pestañas de configuración",
- "scrollTabsRight": "Desplazar pestañas a la derecha"
+ }
},
"modals": {
"uploadDoc": {
@@ -1647,7 +1642,17 @@
"increaseZoom": "Aumentar zoom"
},
"navigation": {
- "agents": "Agentes"
+ "agents": "Agentes",
+ "backToApp": "Volver a la app",
+ "backToAgents": "Volver a agentes",
+ "sections": {
+ "groups": {
+ "personal": "Personal",
+ "workspace": "Espacio de trabajo",
+ "insights": "Estadísticas",
+ "administration": "Administración"
+ }
+ }
},
"teams": {
"switcher": {
diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json
index 18626d19..533e49a8 100644
--- a/frontend/src/locale/jp.json
+++ b/frontend/src/locale/jp.json
@@ -436,7 +436,6 @@
"label": "チーム",
"subtitle": "チーム、メンバー、共有リソースを管理します。",
"newTeam": "新しいチーム",
- "backToTeams": "チーム一覧に戻る",
"createTeam": "チームを作成",
"createTeamDescription": "チームに名前を付けてください。作成後にメンバーの追加やリソースの共有ができます。",
"editTeam": "編集",
@@ -565,7 +564,6 @@
"queryParameters": "クエリパラメータ",
"body": "ボディ",
"deleteActionWarning": "アクション \"{{name}}\" を削除してもよろしいですか?",
- "backToAllTools": "すべてのツールに戻る",
"save": "保存",
"saving": "保存中...",
"saveFailed": "ツール設定の保存に失敗しました",
@@ -872,10 +870,7 @@
"subtitle": "「{{name}}」の新しいシークレットの準備ができました。以前のものは使用できません。"
}
}
- },
- "scrollTabsLeft": "タブを左にスクロール",
- "tabsAriaLabel": "設定タブ",
- "scrollTabsRight": "タブを右にスクロール"
+ }
},
"modals": {
"uploadDoc": {
@@ -1647,7 +1642,17 @@
"increaseZoom": "ズームイン"
},
"navigation": {
- "agents": "エージェント"
+ "agents": "エージェント",
+ "backToApp": "アプリに戻る",
+ "backToAgents": "エージェントに戻る",
+ "sections": {
+ "groups": {
+ "personal": "個人",
+ "workspace": "ワークスペース",
+ "insights": "インサイト",
+ "administration": "管理"
+ }
+ }
},
"teams": {
"switcher": {
diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json
index 081860c6..46c7f03a 100644
--- a/frontend/src/locale/ru.json
+++ b/frontend/src/locale/ru.json
@@ -436,7 +436,6 @@
"label": "Команды",
"subtitle": "Управляйте командами, участниками и общими ресурсами.",
"newTeam": "Новая команда",
- "backToTeams": "Назад к командам",
"createTeam": "Создать команду",
"createTeamDescription": "Дайте команде название. После создания вы сможете добавить участников и делиться ресурсами.",
"editTeam": "Редактировать",
@@ -565,7 +564,6 @@
"queryParameters": "Параметры запроса",
"body": "Тело запроса",
"deleteActionWarning": "Вы уверены, что хотите удалить действие \"{{name}}\"?",
- "backToAllTools": "Вернуться ко всем инструментам",
"save": "Сохранить",
"saving": "Сохранение...",
"saveFailed": "Не удалось сохранить настройки инструмента",
@@ -892,10 +890,7 @@
"subtitle": "Новый секрет для «{{name}}» готов. Старый больше не работает."
}
}
- },
- "scrollTabsLeft": "Прокрутить вкладки влево",
- "tabsAriaLabel": "Вкладки настроек",
- "scrollTabsRight": "Прокрутить вкладки вправо"
+ }
},
"modals": {
"uploadDoc": {
@@ -1667,7 +1662,17 @@
"increaseZoom": "Увеличить масштаб"
},
"navigation": {
- "agents": "Агенты"
+ "agents": "Агенты",
+ "backToApp": "Вернуться в приложение",
+ "backToAgents": "Назад к агентам",
+ "sections": {
+ "groups": {
+ "personal": "Личное",
+ "workspace": "Рабочее пространство",
+ "insights": "Аналитика",
+ "administration": "Администрирование"
+ }
+ }
},
"teams": {
"switcher": {
diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json
index 7902cf5a..ea5c5d5b 100644
--- a/frontend/src/locale/zh-TW.json
+++ b/frontend/src/locale/zh-TW.json
@@ -436,7 +436,6 @@
"label": "團隊",
"subtitle": "管理您的團隊、成員與共享資源。",
"newTeam": "新增團隊",
- "backToTeams": "返回團隊列表",
"createTeam": "建立團隊",
"createTeamDescription": "為您的團隊命名。建立後即可新增成員並分享資源。",
"editTeam": "編輯",
@@ -565,7 +564,6 @@
"queryParameters": "查詢參數",
"body": "主體",
"deleteActionWarning": "您確定要刪除操作 \"{{name}}\" 嗎?",
- "backToAllTools": "返回所有工具",
"save": "儲存",
"saving": "儲存中...",
"saveFailed": "儲存工具設定失敗",
@@ -872,10 +870,7 @@
"subtitle": "「{{name}}」的新密鑰已就緒,舊密鑰已失效。"
}
}
- },
- "scrollTabsLeft": "向左捲動標籤",
- "tabsAriaLabel": "設定標籤",
- "scrollTabsRight": "向右捲動標籤"
+ }
},
"modals": {
"uploadDoc": {
@@ -1647,7 +1642,17 @@
"increaseZoom": "放大"
},
"navigation": {
- "agents": "代理"
+ "agents": "代理",
+ "backToApp": "返回應用程式",
+ "backToAgents": "返回代理",
+ "sections": {
+ "groups": {
+ "personal": "個人",
+ "workspace": "工作區",
+ "insights": "洞察",
+ "administration": "管理"
+ }
+ }
},
"teams": {
"switcher": {
diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json
index 7e647c5e..85baa522 100644
--- a/frontend/src/locale/zh.json
+++ b/frontend/src/locale/zh.json
@@ -436,7 +436,6 @@
"label": "团队",
"subtitle": "管理您的团队、成员和共享资源。",
"newTeam": "新建团队",
- "backToTeams": "返回团队列表",
"createTeam": "创建团队",
"createTeamDescription": "为您的团队命名。创建后即可添加成员并共享资源。",
"editTeam": "编辑",
@@ -565,7 +564,6 @@
"queryParameters": "查询参数",
"body": "请求体",
"deleteActionWarning": "您确定要删除操作 \"{{name}}\" 吗?",
- "backToAllTools": "返回所有工具",
"save": "保存",
"saving": "保存中...",
"saveFailed": "保存工具配置失败",
@@ -872,10 +870,7 @@
"subtitle": "“{{name}}”的新密钥已就绪,旧密钥已失效。"
}
}
- },
- "scrollTabsLeft": "向左滚动标签",
- "tabsAriaLabel": "设置标签",
- "scrollTabsRight": "向右滚动标签"
+ }
},
"modals": {
"uploadDoc": {
@@ -1647,7 +1642,17 @@
"increaseZoom": "放大"
},
"navigation": {
- "agents": "代理"
+ "agents": "代理",
+ "backToApp": "返回应用",
+ "backToAgents": "返回代理",
+ "sections": {
+ "groups": {
+ "personal": "个人",
+ "workspace": "工作区",
+ "insights": "洞察",
+ "administration": "管理"
+ }
+ }
},
"teams": {
"switcher": {
diff --git a/frontend/src/modals/ImportAgentModal.tsx b/frontend/src/modals/ImportAgentModal.tsx
index a0fa7753..47cc573d 100644
--- a/frontend/src/modals/ImportAgentModal.tsx
+++ b/frontend/src/modals/ImportAgentModal.tsx
@@ -18,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from '../components/ui/select';
+import { agentEditPath } from '../agents/paths';
import { ActiveState } from '../models/misc';
import { selectSourceDocs, selectToken } from '../preferences/preferenceSlice';
@@ -215,8 +216,8 @@ export default function ImportAgentModal({
const agentId = data.agent_id as string;
const editPath =
data.agent_type === 'workflow'
- ? `/agents/workflow/edit/${agentId}`
- : `/agents/edit/${agentId}`;
+ ? agentEditPath(agentId, true)
+ : agentEditPath(agentId);
if (data.warnings && data.warnings.length > 0) {
// Keep the modal open so the user sees what was skipped.
setGoToEditPath(editPath);
diff --git a/frontend/src/navigation/DetailBreadcrumb.tsx b/frontend/src/navigation/DetailBreadcrumb.tsx
new file mode 100644
index 00000000..6f007a02
--- /dev/null
+++ b/frontend/src/navigation/DetailBreadcrumb.tsx
@@ -0,0 +1,53 @@
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from '@/components/ui/breadcrumb';
+import { cn } from '@/lib/utils';
+
+type DetailBreadcrumbProps = {
+ parentLabel: string;
+ currentLabel: string;
+ /** Returns to the list view; kept a callback because these detail views are
+ * component state rather than routes, and some guard unsaved changes. */
+ onParentClick: () => void;
+ className?: string;
+};
+
+/**
+ * Trail for a detail view nested inside a section page (a tool's config, a
+ * team). The section nav's back button always means "leave the section", so
+ * going up one level is a breadcrumb here rather than a second back arrow.
+ */
+export default function DetailBreadcrumb({
+ parentLabel,
+ currentLabel,
+ onParentClick,
+ className,
+}: DetailBreadcrumbProps) {
+ return (
+
+
+
+
+
+ {parentLabel}
+
+
+
+
+
+
+ {currentLabel}
+
+
+
+
+ );
+}
diff --git a/frontend/src/navigation/SectionIndexPage.tsx b/frontend/src/navigation/SectionIndexPage.tsx
new file mode 100644
index 00000000..ba75711a
--- /dev/null
+++ b/frontend/src/navigation/SectionIndexPage.tsx
@@ -0,0 +1,65 @@
+import { ChevronRight } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { useSelector } from 'react-redux';
+import { Link } from 'react-router-dom';
+
+import { selectIsAdmin } from '@/preferences/preferenceSlice';
+
+import { getVisibleGroups, type Section } from './sections';
+
+/**
+ * Small-screen landing page for a section: the same destinations as the
+ * sidebar nav, rendered as page content so they stay reachable while the
+ * sidebar is an overlay. Tapping a row pushes its page, which carries a back
+ * link to here.
+ */
+export default function SectionIndexPage({ section }: { section: Section }) {
+ const { t } = useTranslation();
+ const isAdmin = useSelector(selectIsAdmin);
+ const groups = getVisibleGroups(section, { isAdmin });
+
+ return (
+
+
+ {section.title ?? t(section.titleKey)}
+
+
+ {groups.map((group) => (
+
+ {group.labelKey && (
+
+ {t(group.labelKey)}
+
+ )}
+
+ {group.items.map((item) => {
+ const Icon = item.icon;
+ return (
+
+
+
+ {t(item.labelKey)}
+
+
+
+ );
+ })}
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/navigation/SectionNav.tsx b/frontend/src/navigation/SectionNav.tsx
new file mode 100644
index 00000000..ec1ff63b
--- /dev/null
+++ b/frontend/src/navigation/SectionNav.tsx
@@ -0,0 +1,114 @@
+import { ArrowLeft } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+
+import { cn } from '@/lib/utils';
+
+import { getVisibleGroups, type Section, type SectionItem } from './sections';
+import { useSidebarLevel } from './SidebarLevelProvider';
+
+type SectionNavProps = {
+ section: Section;
+ /** Key of the item matching the current route, from ``getActiveItem``. */
+ activeItemKey?: string;
+ isAdmin: boolean;
+ /** Leaves the section — always "exit", never "up one level". */
+ onBack: () => void;
+ backLabel: string;
+ onNavigate?: () => void;
+};
+
+/**
+ * Vertical nav that replaces the chat list while the user is inside a section.
+ *
+ * The only back affordance at this level is ``onBack``, which exits the
+ * section; anything deeper (a tool's config, a team's detail) keeps its own
+ * breadcrumb in the content column so the two never compete.
+ */
+export default function SectionNav({
+ section,
+ activeItemKey,
+ isAdmin,
+ onBack,
+ backLabel,
+ onNavigate,
+}: SectionNavProps) {
+ const { t } = useTranslation();
+ const { goToLevel } = useSidebarLevel();
+ const groups = getVisibleGroups(section, { isAdmin });
+ const sectionTitle = section.title ?? t(section.titleKey);
+
+ const renderItem = (item: SectionItem) => {
+ const isActive = item.key === activeItemKey;
+ const Icon = item.icon;
+ return (
+
{
+ // Same level, so nothing slides — but routing through the level
+ // provider still renders the page at low priority, which keeps the
+ // highlight moving under the cursor instead of after the mount.
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+ event.preventDefault();
+ goToLevel(item.path);
+ onNavigate?.();
+ }}
+ aria-current={isActive ? 'page' : undefined}
+ className={cn(
+ 'hover:bg-sidebar-accent mx-4 my-1 flex h-9 cursor-pointer items-center gap-2.5 rounded-3xl pl-3',
+ isActive && 'bg-sidebar-accent',
+ )}
+ >
+
+
+ {t(item.labelKey)}
+
+
+ );
+ };
+
+ return (
+
+
+
+
+ {backLabel}
+
+
+
+ {sectionTitle}
+
+
+ {groups.map((group) => (
+
+ {group.labelKey && (
+
+ {t(group.labelKey)}
+
+ )}
+ {group.items.map(renderItem)}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/navigation/SectionPageHeader.tsx b/frontend/src/navigation/SectionPageHeader.tsx
new file mode 100644
index 00000000..8104dd94
--- /dev/null
+++ b/frontend/src/navigation/SectionPageHeader.tsx
@@ -0,0 +1,94 @@
+import { ArrowLeft } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Link, useLocation } from 'react-router-dom';
+
+import { useMediaQuery } from '@/hooks';
+import { cn } from '@/lib/utils';
+
+import { getSectionForPath, type Section, type SectionItem } from './sections';
+import { useSectionContext } from './useSectionContext';
+
+/**
+ * Back to the section's index. Only rendered below ``lg``, where the sidebar
+ * is an overlay and the section nav would otherwise sit behind the hamburger.
+ * On desktop the sidebar itself is the way back, so this stays out of the way.
+ */
+export function SectionBackLink({
+ section,
+ className,
+}: {
+ section: Section;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ const { pathname } = useLocation();
+ const { isMobile, isTablet } = useMediaQuery();
+
+ if (!(isMobile || isTablet)) return null;
+
+ // Up one level, matching the sidebar's back button: out of an agent lands
+ // on the agent list, out of a settings page on the settings index. A
+ // section whose destinations are views of one page has no level above
+ // unless it declares a parent — its pill row does the moving around.
+ const to =
+ section.parentPath ??
+ (section.pageTitle === 'section' ? null : section.rootPath);
+ if (!to || to === pathname) return null;
+
+ const parent = section.parentPath
+ ? getSectionForPath(section.parentPath)
+ : null;
+ return (
+
+
+ {parent ? t(parent.titleKey) : (section.title ?? t(section.titleKey))}
+
+ );
+}
+
+/**
+ * Title block for a section page: the active item's name, preceded on small
+ * screens by a link back to the section index.
+ */
+export default function SectionPageHeader({
+ section,
+ item,
+ className,
+}: {
+ section: Section;
+ item: SectionItem | null;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {item && section.pageTitle !== 'section'
+ ? t(item.labelKey)
+ : (section.title ?? t(section.titleKey))}
+
+
+ );
+}
+
+/**
+ * The title block for whichever section page is on screen. Saves every page
+ * from resolving its own section, and keeps the heading identical across
+ * settings, admin and agents.
+ */
+export function CurrentSectionHeader({ className }: { className?: string }) {
+ const { section, item } = useSectionContext();
+
+ if (!section) return null;
+ return (
+
+ );
+}
diff --git a/frontend/src/navigation/SectionPills.tsx b/frontend/src/navigation/SectionPills.tsx
new file mode 100644
index 00000000..4778f8f4
--- /dev/null
+++ b/frontend/src/navigation/SectionPills.tsx
@@ -0,0 +1,59 @@
+import { useTranslation } from 'react-i18next';
+import { useSelector } from 'react-redux';
+import { Link } from 'react-router-dom';
+
+import { cn } from '@/lib/utils';
+import { selectIsAdmin } from '@/preferences/preferenceSlice';
+
+import { getVisibleGroups } from './sections';
+import { useSectionContext } from './useSectionContext';
+
+/**
+ * The current section's destinations as a pill row, shown only below `lg`
+ * where the sidebar is an overlay.
+ *
+ * Sections whose destinations are separate pages (settings, admin) use
+ * `SectionIndexPage` instead. This is for sections whose destinations are
+ * views of the page you are already on — the agent list's filters, an
+ * agent's own pages — where bouncing out to a menu to switch would be worse
+ * than a row of pills.
+ */
+export default function SectionPills({ className }: { className?: string }) {
+ const { t } = useTranslation();
+ const { section, item } = useSectionContext();
+ const isAdmin = useSelector(selectIsAdmin);
+
+ if (!section) return null;
+ const items = getVisibleGroups(section, { isAdmin }).flatMap(
+ (group) => group.items,
+ );
+ if (items.length < 2) return null;
+
+ return (
+
+ {items.map((entry) => {
+ const isActive = entry.key === item?.key;
+ return (
+
+ {t(entry.labelKey)}
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/navigation/SectionRail.tsx b/frontend/src/navigation/SectionRail.tsx
new file mode 100644
index 00000000..5ece5227
--- /dev/null
+++ b/frontend/src/navigation/SectionRail.tsx
@@ -0,0 +1,82 @@
+import { ArrowLeft } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+import { getSectionItems, type Section } from './sections';
+import { useSidebarLevel } from './SidebarLevelProvider';
+
+type SectionRailProps = {
+ section: Section;
+ activeItemKey?: string;
+ isAdmin: boolean;
+ onBack: () => void;
+ backLabel: string;
+};
+
+/**
+ * Collapsed-sidebar counterpart to ``SectionNav``: the same destinations as
+ * icons, so collapsing the sidebar inside a section still leaves the section
+ * navigable instead of stranding the user on one page.
+ */
+export default function SectionRail({
+ section,
+ activeItemKey,
+ isAdmin,
+ onBack,
+ backLabel,
+}: SectionRailProps) {
+ const { t } = useTranslation();
+ const { goToLevel } = useSidebarLevel();
+ const items = getSectionItems(section, { isAdmin });
+
+ return (
+ // Keyed on the section so switching level replays the fade: the rail is
+ // too narrow to slide panels through, but it should not swap in place
+ // with no acknowledgement either.
+
+
+
+
+
+ {items.map((item) => {
+ const label = t(item.labelKey);
+ const isActive = item.key === activeItemKey;
+ const Icon = item.icon;
+ return (
+
{
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+ event.preventDefault();
+ goToLevel(item.path);
+ }}
+ aria-label={label}
+ aria-current={isActive ? 'page' : undefined}
+ title={label}
+ className={cn(
+ 'hover:bg-sidebar-accent text-muted-foreground hover:text-foreground flex size-9 items-center justify-center rounded-full',
+ isActive && 'bg-sidebar-accent text-foreground',
+ )}
+ >
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/navigation/SidebarLevel.tsx b/frontend/src/navigation/SidebarLevel.tsx
new file mode 100644
index 00000000..20d15f10
--- /dev/null
+++ b/frontend/src/navigation/SidebarLevel.tsx
@@ -0,0 +1,73 @@
+import type { ReactNode } from 'react';
+
+import { cn } from '@/lib/utils';
+
+type SidebarLevelProps = {
+ /** This panel's place in the hierarchy: 0 chats, 1 a section, 2 a record. */
+ depth: number;
+ /** The level on screen right now. */
+ current: number;
+ children: ReactNode;
+ className?: string;
+};
+
+/**
+ * One panel in the sidebar's navigation stack.
+ *
+ * Every panel is positioned from a single number — its depth relative to the
+ * level on screen — so push and pop fall out of the same rule instead of
+ * needing a direction to be tracked. A panel above the current level waits
+ * off to the right; the current one sits at rest; ones below are parked just
+ * off to the left. Changing level therefore animates both panels the right
+ * way round, whichever way the user is going.
+ *
+ * The panel behind only travels a quarter of the width, so it trails the
+ * incoming panel rather than marching with it — the cue that one sits on top
+ * of the other rather than beside it. Each panel paints its own background
+ * so it occludes the one behind while it slides.
+ *
+ * `visibility` is in the transition on purpose: CSS keeps an element visible
+ * for the whole duration when either end of the transition is `visible`, so a
+ * panel stays on screen while it leaves and only drops out of the tab order
+ * once it has gone. Panels are never unmounted, which is what lets the chat
+ * list keep its scroll position across a trip into settings.
+ */
+export default function SidebarLevel({
+ depth,
+ current,
+ children,
+ className,
+}: SidebarLevelProps) {
+ const offset = depth - current;
+
+ return (
+
0 && 'invisible translate-x-full opacity-100',
+ className,
+ )}
+ >
+ {children}
+
+ );
+}
diff --git a/frontend/src/navigation/SidebarLevelProvider.tsx b/frontend/src/navigation/SidebarLevelProvider.tsx
new file mode 100644
index 00000000..37d766d6
--- /dev/null
+++ b/frontend/src/navigation/SidebarLevelProvider.tsx
@@ -0,0 +1,82 @@
+import {
+ createContext,
+ startTransition,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+
+import type { Section } from './sections';
+import { useSectionResolver } from './useSectionResolver';
+
+type PendingLevel = { pathname: string; section: Section | null };
+
+type SidebarLevelValue = {
+ /** The level the sidebar should show, ahead of the route when moving. */
+ pending: PendingLevel | null;
+ /** Navigate in a way the sidebar can animate immediately. */
+ goToLevel: (to: string) => void;
+};
+
+const SidebarLevelContext = createContext
({
+ pending: null,
+ goToLevel: () => {},
+});
+
+/**
+ * Lets the sidebar change level on the click rather than on the commit.
+ *
+ * Mounting a section's page is expensive — measured at a single ~170ms
+ * blocking frame in a production build — and the sidebar's own class change
+ * used to ride along in that same commit. The panels therefore only began
+ * moving once the new page had rendered: a pause, and then a slide the user
+ * had stopped expecting.
+ *
+ * So the two updates are split by priority. The level lands as an urgent
+ * update that touches nothing but the sidebar, so React can commit and paint
+ * it straight away and the transition starts on time; the route change goes
+ * through `startTransition`, which renders the page at low priority and
+ * yields between slices instead of blocking that paint. The target's section
+ * is resolved up front so the incoming panel slides in with its content
+ * already in place rather than arriving empty.
+ */
+export function SidebarLevelProvider({ children }: { children: ReactNode }) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const resolve = useSectionResolver();
+ const [pending, setPending] = useState(null);
+
+ const goToLevel = useCallback(
+ (to: string) => {
+ const pathname = to.split('?')[0];
+ setPending({ pathname, section: resolve(pathname) });
+ startTransition(() => navigate(to));
+ },
+ [navigate, resolve],
+ );
+
+ // Hand back to the route once it catches up, and never hold the sidebar
+ // ahead of it for long: a navigation can be refused (an unsaved-changes
+ // guard) or land somewhere else entirely, and a level that never resolved
+ // would leave the sidebar showing a section the user is not in.
+ useEffect(() => {
+ if (!pending) return;
+ if (pending.pathname === location.pathname) {
+ setPending(null);
+ return;
+ }
+ const timer = setTimeout(() => setPending(null), 600);
+ return () => clearTimeout(timer);
+ }, [pending, location.pathname]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useSidebarLevel = () => useContext(SidebarLevelContext);
diff --git a/frontend/src/navigation/sections.test.ts b/frontend/src/navigation/sections.test.ts
new file mode 100644
index 00000000..31c6ff1d
--- /dev/null
+++ b/frontend/src/navigation/sections.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ ADMIN_SECTION,
+ AGENTS_SECTION,
+ buildAgentSection,
+ depthOf,
+ getActiveItem,
+ getSectionForPath,
+ getSectionItems,
+ getVisibleGroups,
+ SETTINGS_SECTION,
+} from './sections';
+
+describe('getSectionForPath', () => {
+ it('claims the section root and everything under it', () => {
+ expect(getSectionForPath('/settings')?.key).toBe('settings');
+ expect(getSectionForPath('/settings/tools')?.key).toBe('settings');
+ expect(getSectionForPath('/admin/users')?.key).toBe('admin');
+ });
+
+ it('claims routes the section owns outside its root', () => {
+ expect(getSectionForPath('/teams')?.key).toBe('settings');
+ });
+
+ it('leaves ordinary app routes alone', () => {
+ expect(getSectionForPath('/')).toBeNull();
+ expect(getSectionForPath('/c/abc123')).toBeNull();
+ });
+
+ it('separates managing agents from chatting with one', () => {
+ expect(getSectionForPath('/agents/manage')?.key).toBe('agents');
+ expect(getSectionForPath('/agents/manage/edit/a1')?.key).toBe('agents');
+
+ // A conversation with an agent must leave the chat list in place.
+ expect(getSectionForPath('/agents/a1/c/c1')).toBeNull();
+ expect(getSectionForPath('/agents/shared/tok')).toBeNull();
+ });
+
+ it('matches whole path segments, not string prefixes', () => {
+ expect(getSectionForPath('/settings-export')).toBeNull();
+ expect(getSectionForPath('/administrators')).toBeNull();
+ });
+});
+
+describe('getActiveItem', () => {
+ it('resolves the section root through the alias', () => {
+ expect(getActiveItem(SETTINGS_SECTION, '/settings')?.key).toBe('general');
+ expect(getActiveItem(ADMIN_SECTION, '/admin')?.key).toBe('overview');
+ });
+
+ it('prefers the deepest match over a shorter alias', () => {
+ expect(getActiveItem(SETTINGS_SECTION, '/settings/tools')?.key).toBe(
+ 'tools',
+ );
+ expect(
+ getActiveItem(SETTINGS_SECTION, '/settings/access-tokens')?.key,
+ ).toBe('accessTokens');
+ });
+
+ it('keeps the parent item active inside a detail route', () => {
+ expect(getActiveItem(SETTINGS_SECTION, '/settings/tools/slack')?.key).toBe(
+ 'tools',
+ );
+ });
+
+ it('round-trips every configured item', () => {
+ for (const section of [SETTINGS_SECTION, ADMIN_SECTION]) {
+ for (const item of getSectionItems(section)) {
+ // Items pointing at another section are drill-throughs, not
+ // destinations this section can be "on".
+ if (item.leavesSection) continue;
+ expect(getActiveItem(section, item.path)?.key).toBe(item.key);
+ }
+ }
+ });
+});
+
+describe('getVisibleGroups', () => {
+ it('hides admin-only entries and the group left empty by them', () => {
+ const groups = getVisibleGroups(SETTINGS_SECTION, { isAdmin: false });
+ const keys = groups.flatMap((group) => group.items.map((i) => i.key));
+
+ expect(keys).not.toContain('admin');
+ expect(groups.map((group) => group.key)).not.toContain('administration');
+ expect(keys).toContain('general');
+ });
+
+ it('shows them to admins', () => {
+ const keys = getVisibleGroups(SETTINGS_SECTION, { isAdmin: true }).flatMap(
+ (group) => group.items.map((i) => i.key),
+ );
+
+ expect(keys).toContain('admin');
+ });
+});
+
+describe('AGENTS_SECTION', () => {
+ it('gives every list filter its own route', () => {
+ expect(getActiveItem(AGENTS_SECTION, '/agents/manage')?.key).toBe('all');
+ expect(getActiveItem(AGENTS_SECTION, '/agents/manage/mine')?.key).toBe(
+ 'user',
+ );
+ expect(
+ getActiveItem(AGENTS_SECTION, '/agents/manage/discovered')?.key,
+ ).toBe('shared');
+ });
+});
+
+describe('buildAgentSection', () => {
+ it('is named after the agent and goes back to the list', () => {
+ const section = buildAgentSection('a1', 'Support bot', false);
+
+ expect(section.title).toBe('Support bot');
+ expect(section.parentPath).toBe('/agents/manage');
+ });
+
+ it('falls back to a generic title before the agent has loaded', () => {
+ expect(buildAgentSection('a1', undefined, false).title).toBeUndefined();
+ expect(buildAgentSection('a1', ' ', false).title).toBeUndefined();
+ });
+
+ it('resolves each of the agent pages', () => {
+ const section = buildAgentSection('a1', 'Support bot', false);
+
+ expect(getActiveItem(section, '/agents/manage/edit/a1')?.key).toBe(
+ 'overview',
+ );
+ expect(getActiveItem(section, '/agents/manage/logs/a1')?.key).toBe('logs');
+ expect(getActiveItem(section, '/agents/manage/schedules/a1')?.key).toBe(
+ 'schedules',
+ );
+ });
+
+ it('points overview at the workflow builder for a workflow agent', () => {
+ const section = buildAgentSection('a1', 'Flow', true);
+
+ expect(section.rootPath).toBe('/agents/manage/workflow/edit/a1');
+ expect(getActiveItem(section, '/agents/manage/workflow/edit/a1')?.key).toBe(
+ 'overview',
+ );
+ });
+});
+
+describe('depthOf', () => {
+ it('puts chats, sections and records on their own level', () => {
+ expect(depthOf(null)).toBe(0);
+ expect(depthOf(getSectionForPath('/settings'))).toBe(1);
+ expect(depthOf(getSectionForPath('/agents/manage'))).toBe(1);
+ expect(depthOf(getSectionForPath('/admin/users'))).toBe(1);
+ expect(depthOf(buildAgentSection('a1', 'Support bot', false))).toBe(2);
+ });
+
+ it('keeps a chat with an agent at the chat level', () => {
+ expect(depthOf(getSectionForPath('/agents/a1/c/c1'))).toBe(0);
+ });
+});
diff --git a/frontend/src/navigation/sections.ts b/frontend/src/navigation/sections.ts
new file mode 100644
index 00000000..5eb73b39
--- /dev/null
+++ b/frontend/src/navigation/sections.ts
@@ -0,0 +1,387 @@
+import {
+ BarChart3,
+ Blocks,
+ CalendarClock,
+ ChartNoAxesColumn,
+ Database,
+ FileClock,
+ Gauge,
+ Globe,
+ KeyRound,
+ LayoutDashboard,
+ LayoutGrid,
+ LayoutTemplate,
+ ScrollText,
+ Settings2,
+ ShieldCheck,
+ SquarePen,
+ User,
+ UserCog,
+ Users,
+ Wrench,
+ type LucideIcon,
+} from 'lucide-react';
+import {
+ AGENTS_MANAGE_ROOT,
+ agentEditPath,
+ agentLogsPath,
+ agentSchedulesPath,
+ agentsFilterPath,
+} from '../agents/paths';
+
+/** A single destination in a section's vertical nav. */
+export type SectionItem = {
+ key: string;
+ path: string;
+ labelKey: string;
+ icon: LucideIcon;
+ /** Extra pathnames that also mark this item active (e.g. the section root). */
+ aliases?: string[];
+ /** Hidden from users without the global admin role. */
+ adminOnly?: boolean;
+ /** Jumps to a different section rather than navigating within this one. */
+ leavesSection?: boolean;
+};
+
+/** Items sharing a heading in the nav. */
+export type SectionGroup = {
+ key: string;
+ labelKey?: string;
+ items: SectionItem[];
+};
+
+/**
+ * A top-level area that takes over the sidebar while the user is inside it.
+ * `matches` lists the pathnames the section owns — entering any of them swaps
+ * the sidebar from the chat list to this section's nav.
+ */
+export type Section = {
+ key: string;
+ rootPath: string;
+ /** Fallback title; `title` overrides it when the name comes from data. */
+ titleKey: string;
+ /** Literal title for a section named after a record, e.g. an agent. */
+ title?: string;
+ matches: string[];
+ /**
+ * Where the back button goes. Sections nest — leaving an agent lands on the
+ * agent list, not the chat — so back always means "up one level", and the
+ * top level is the app.
+ */
+ parentPath?: string;
+ parentLabelKey?: string;
+ /**
+ * What the page heading says. `'item'` (the default) suits sections whose
+ * destinations are separate pages; `'section'` suits ones whose
+ * destinations are views of a single page, like the agent list's filters,
+ * where the heading would otherwise flip to "All" or "My agents".
+ */
+ pageTitle?: 'item' | 'section';
+ groups: SectionGroup[];
+};
+
+export const SETTINGS_SECTION: Section = {
+ key: 'settings',
+ rootPath: '/settings',
+ titleKey: 'settings.label',
+ matches: ['/settings', '/teams'],
+ groups: [
+ {
+ key: 'personal',
+ labelKey: 'navigation.sections.groups.personal',
+ items: [
+ {
+ key: 'general',
+ path: '/settings/general',
+ labelKey: 'settings.general.label',
+ icon: Settings2,
+ aliases: ['/settings'],
+ },
+ {
+ key: 'accessTokens',
+ path: '/settings/access-tokens',
+ labelKey: 'settings.accessTokens.label',
+ icon: KeyRound,
+ },
+ ],
+ },
+ {
+ key: 'workspace',
+ labelKey: 'navigation.sections.groups.workspace',
+ items: [
+ {
+ key: 'sources',
+ path: '/settings/sources',
+ labelKey: 'settings.sources.label',
+ icon: Database,
+ },
+ {
+ key: 'tools',
+ path: '/settings/tools',
+ labelKey: 'settings.tools.label',
+ icon: Wrench,
+ },
+ {
+ key: 'customModels',
+ path: '/settings/custom-models',
+ labelKey: 'settings.customModels.label',
+ icon: Blocks,
+ },
+ {
+ key: 'teams',
+ path: '/teams',
+ labelKey: 'settings.teams.label',
+ icon: Users,
+ },
+ ],
+ },
+ {
+ key: 'insights',
+ labelKey: 'navigation.sections.groups.insights',
+ items: [
+ {
+ key: 'analytics',
+ path: '/settings/analytics',
+ labelKey: 'settings.analytics.label',
+ icon: ChartNoAxesColumn,
+ },
+ {
+ key: 'logs',
+ path: '/settings/logs',
+ labelKey: 'settings.logs.label',
+ icon: ScrollText,
+ },
+ ],
+ },
+ {
+ key: 'administration',
+ labelKey: 'navigation.sections.groups.administration',
+ items: [
+ {
+ key: 'admin',
+ path: '/admin',
+ labelKey: 'admin.label',
+ icon: ShieldCheck,
+ adminOnly: true,
+ leavesSection: true,
+ },
+ ],
+ },
+ ],
+};
+
+export const ADMIN_SECTION: Section = {
+ key: 'admin',
+ rootPath: '/admin',
+ titleKey: 'admin.label',
+ matches: ['/admin'],
+ groups: [
+ {
+ key: 'admin',
+ items: [
+ {
+ key: 'overview',
+ path: '/admin/overview',
+ labelKey: 'admin.tabs.overview',
+ icon: LayoutDashboard,
+ aliases: ['/admin'],
+ },
+ {
+ key: 'users',
+ path: '/admin/users',
+ labelKey: 'admin.tabs.users',
+ icon: Users,
+ },
+ {
+ key: 'admins',
+ path: '/admin/roles',
+ labelKey: 'admin.tabs.admins',
+ icon: UserCog,
+ },
+ {
+ key: 'usage',
+ path: '/admin/usage',
+ labelKey: 'admin.tabs.usage',
+ icon: BarChart3,
+ },
+ {
+ key: 'quotas',
+ path: '/admin/quotas',
+ labelKey: 'admin.tabs.quotas',
+ icon: Gauge,
+ },
+ {
+ key: 'audit',
+ path: '/admin/audit',
+ labelKey: 'admin.tabs.audit',
+ icon: FileClock,
+ },
+ ],
+ },
+ ],
+};
+
+export const AGENTS_SECTION: Section = {
+ key: 'agents',
+ rootPath: AGENTS_MANAGE_ROOT,
+ titleKey: 'agents.title',
+ // Only the management prefix. `/agents/:id/c/:conversationId` is a chat and
+ // must leave the sidebar on the conversation list.
+ matches: [AGENTS_MANAGE_ROOT],
+ pageTitle: 'section',
+ groups: [
+ {
+ key: 'agents',
+ items: [
+ {
+ key: 'all',
+ path: agentsFilterPath('all'),
+ labelKey: 'agents.filters.all',
+ icon: LayoutGrid,
+ },
+ {
+ key: 'template',
+ path: agentsFilterPath('template'),
+ labelKey: 'agents.filters.byDocsGPT',
+ icon: LayoutTemplate,
+ },
+ {
+ key: 'user',
+ path: agentsFilterPath('user'),
+ labelKey: 'agents.filters.byMe',
+ icon: User,
+ },
+ {
+ key: 'team',
+ path: agentsFilterPath('team'),
+ labelKey: 'agents.filters.team',
+ icon: Users,
+ },
+ {
+ key: 'shared',
+ path: agentsFilterPath('shared'),
+ labelKey: 'agents.filters.shared',
+ icon: Globe,
+ },
+ ],
+ },
+ ],
+};
+
+/**
+ * The nav for a single agent. Built per route rather than declared, because
+ * its title is the agent's name and its paths carry the agent's id.
+ */
+export function buildAgentSection(
+ agentId: string,
+ agentName: string | undefined,
+ workflow: boolean,
+): Section {
+ return {
+ key: `agent:${agentId}`,
+ rootPath: agentEditPath(agentId, workflow),
+ titleKey: 'agents.pageHeader.fallbackName',
+ title: agentName?.trim() || undefined,
+ matches: [
+ agentEditPath(agentId, workflow),
+ agentLogsPath(agentId),
+ agentSchedulesPath(agentId),
+ ],
+ parentPath: AGENTS_MANAGE_ROOT,
+ parentLabelKey: 'navigation.backToAgents',
+ groups: [
+ {
+ key: 'agent',
+ items: [
+ {
+ key: 'overview',
+ path: agentEditPath(agentId, workflow),
+ labelKey: 'agents.pageHeader.tabs.overview',
+ icon: SquarePen,
+ },
+ {
+ key: 'logs',
+ path: agentLogsPath(agentId),
+ labelKey: 'agents.pageHeader.tabs.logs',
+ icon: ScrollText,
+ },
+ {
+ key: 'schedules',
+ path: agentSchedulesPath(agentId),
+ labelKey: 'agents.pageHeader.tabs.schedules',
+ icon: CalendarClock,
+ },
+ ],
+ },
+ ],
+ };
+}
+
+export const SECTIONS: Section[] = [
+ SETTINGS_SECTION,
+ ADMIN_SECTION,
+ AGENTS_SECTION,
+];
+
+const pathMatches = (pathname: string, path: string): boolean =>
+ pathname === path || pathname.startsWith(`${path}/`);
+
+/** The section owning ``pathname``, or null when it is an ordinary app route. */
+export function getSectionForPath(pathname: string): Section | null {
+ return (
+ SECTIONS.find((section) =>
+ section.matches.some((match) => pathMatches(pathname, match)),
+ ) ?? null
+ );
+}
+
+/** Flattened items of a section, optionally dropping admin-only entries. */
+export function getSectionItems(
+ section: Section,
+ { isAdmin = true }: { isAdmin?: boolean } = {},
+): SectionItem[] {
+ return section.groups
+ .flatMap((group) => group.items)
+ .filter((item) => !item.adminOnly || isAdmin);
+}
+
+/** Groups with admin-only entries removed, dropping any group left empty. */
+export function getVisibleGroups(
+ section: Section,
+ { isAdmin = true }: { isAdmin?: boolean } = {},
+): SectionGroup[] {
+ return section.groups
+ .map((group) => ({
+ ...group,
+ items: group.items.filter((item) => !item.adminOnly || isAdmin),
+ }))
+ .filter((group) => group.items.length > 0);
+}
+
+/**
+ * The nav item ``pathname`` belongs to. Longest match wins, so a deeper route
+ * (``/settings/tools/slack``) beats a shorter alias (``/settings``).
+ */
+export function getActiveItem(
+ section: Section,
+ pathname: string,
+): SectionItem | null {
+ let best: SectionItem | null = null;
+ let bestLength = -1;
+ for (const item of getSectionItems(section)) {
+ for (const candidate of [item.path, ...(item.aliases ?? [])]) {
+ if (pathMatches(pathname, candidate) && candidate.length > bestLength) {
+ best = item;
+ bestLength = candidate.length;
+ }
+ }
+ }
+ return best;
+}
+
+/**
+ * Which level of the sidebar stack a section occupies: the chat list, a
+ * section, or a record inside one.
+ */
+export const depthOf = (section: Section | null): number =>
+ section ? (section.parentPath ? 2 : 1) : 0;
diff --git a/frontend/src/navigation/useLastAppPath.ts b/frontend/src/navigation/useLastAppPath.ts
new file mode 100644
index 00000000..72ce5d71
--- /dev/null
+++ b/frontend/src/navigation/useLastAppPath.ts
@@ -0,0 +1,20 @@
+import { useEffect, useRef } from 'react';
+import { useLocation } from 'react-router-dom';
+
+import { getSectionForPath } from './sections';
+
+/**
+ * Remembers the last route outside any section, so leaving a section returns
+ * the user to the conversation they were in rather than to a blank chat.
+ */
+export function useLastAppPath(fallback = '/') {
+ const location = useLocation();
+ const lastAppPath = useRef(fallback);
+
+ useEffect(() => {
+ if (!getSectionForPath(location.pathname))
+ lastAppPath.current = `${location.pathname}${location.search}`;
+ }, [location.pathname, location.search]);
+
+ return lastAppPath;
+}
diff --git a/frontend/src/navigation/useSectionContext.ts b/frontend/src/navigation/useSectionContext.ts
new file mode 100644
index 00000000..c8296398
--- /dev/null
+++ b/frontend/src/navigation/useSectionContext.ts
@@ -0,0 +1,54 @@
+import { useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { useLocation } from 'react-router-dom';
+
+import { matchAgentScopedRoute } from '../agents/paths';
+import {
+ selectAgents,
+ selectSelectedAgent,
+ selectSharedAgents,
+} from '../preferences/preferenceSlice';
+import {
+ buildAgentSection,
+ getActiveItem,
+ getSectionForPath,
+ type Section,
+ type SectionItem,
+} from './sections';
+
+/**
+ * The section the current route belongs to, and the nav item within it.
+ *
+ * Most sections are static and come straight from the registry. A route
+ * scoped to one agent gets a section built on the spot, since its title is
+ * the agent's name — which lives in the store, not in the path.
+ */
+export function useSectionContext(): {
+ section: Section | null;
+ item: SectionItem | null;
+} {
+ const { pathname } = useLocation();
+ const agents = useSelector(selectAgents);
+ const sharedAgents = useSelector(selectSharedAgents);
+ const selectedAgent = useSelector(selectSelectedAgent);
+
+ const section = useMemo(() => {
+ const scoped = matchAgentScopedRoute(pathname);
+ if (!scoped) return getSectionForPath(pathname);
+
+ const name = [
+ ...(agents ?? []),
+ ...(sharedAgents ?? []),
+ ...(selectedAgent ? [selectedAgent] : []),
+ ].find((agent) => agent.id === scoped.agentId)?.name;
+
+ // An agent saved moments ago may not be in the store yet; the section
+ // falls back to a generic title until it arrives.
+ return buildAgentSection(scoped.agentId, name, scoped.workflow);
+ }, [pathname, agents, sharedAgents, selectedAgent]);
+
+ return {
+ section,
+ item: section ? getActiveItem(section, pathname) : null,
+ };
+}
diff --git a/frontend/src/navigation/useSectionResolver.ts b/frontend/src/navigation/useSectionResolver.ts
new file mode 100644
index 00000000..5e509639
--- /dev/null
+++ b/frontend/src/navigation/useSectionResolver.ts
@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+
+import { matchAgentScopedRoute } from '../agents/paths';
+import {
+ selectAgents,
+ selectSelectedAgent,
+ selectSharedAgents,
+} from '../preferences/preferenceSlice';
+import { buildAgentSection, getSectionForPath, type Section } from './sections';
+
+/**
+ * Resolves any pathname to its section, including the ones built per route
+ * from a record in the store. Taking a pathname rather than reading the
+ * current one lets the sidebar resolve a route it is *about* to go to, which
+ * is what allows it to start moving on the click.
+ */
+export function useSectionResolver(): (pathname: string) => Section | null {
+ const agents = useSelector(selectAgents);
+ const sharedAgents = useSelector(selectSharedAgents);
+ const selectedAgent = useSelector(selectSelectedAgent);
+
+ return useCallback(
+ (pathname: string) => {
+ const scoped = matchAgentScopedRoute(pathname);
+ if (!scoped) return getSectionForPath(pathname);
+
+ const name = [
+ ...(agents ?? []),
+ ...(sharedAgents ?? []),
+ ...(selectedAgent ? [selectedAgent] : []),
+ ].find((agent) => agent.id === scoped.agentId)?.name;
+
+ return buildAgentSection(scoped.agentId, name, scoped.workflow);
+ },
+ [agents, sharedAgents, selectedAgent],
+ );
+}
diff --git a/frontend/src/settings/RemoteDeviceConfig.tsx b/frontend/src/settings/RemoteDeviceConfig.tsx
index f8426410..afc04833 100644
--- a/frontend/src/settings/RemoteDeviceConfig.tsx
+++ b/frontend/src/settings/RemoteDeviceConfig.tsx
@@ -7,10 +7,10 @@ import devicesService, {
AuditEntry,
Device,
} from '../api/services/devicesService';
-import ArrowLeft from '../assets/arrow-left.svg';
import CopyButton from '../components/CopyButton';
import Spinner from '../components/Spinner';
import ToolIcon from '../components/ToolIcon';
+import DetailBreadcrumb from '../navigation/DetailBreadcrumb';
import {
Accordion,
AccordionContent,
@@ -194,19 +194,14 @@ export default function RemoteDeviceConfig({ tool, handleGoBack }: Props) {
return (
-
-
-
-
-
-
{t('settings.tools.backToAllTools')}
-
+
+
+
+
+
@@ -607,20 +612,16 @@ export default function Teams() {
) : (
-
{
+ {
setSelected(null);
setMembers([]);
setGrants([]);
setError(null);
}}
- >
-
- {t('settings.teams.backToTeams')}
-
+ />
diff --git a/frontend/src/settings/ToolConfig.tsx b/frontend/src/settings/ToolConfig.tsx
index 788b1373..6c845131 100644
--- a/frontend/src/settings/ToolConfig.tsx
+++ b/frontend/src/settings/ToolConfig.tsx
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
-import ArrowLeft from '../assets/arrow-left.svg';
import ChevronRight from '../assets/chevron-right.svg';
import CircleCheck from '../assets/circle-check.svg';
import CircleX from '../assets/circle-x.svg';
@@ -24,6 +23,7 @@ import { Switch } from '../components/ui/switch';
import { useDarkTheme } from '../hooks';
import AddActionModal from '../modals/AddActionModal';
import ConfirmationModal from '../modals/ConfirmationModal';
+import DetailBreadcrumb from '../navigation/DetailBreadcrumb';
import ImportSpecModal from '../modals/ImportSpecModal';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
@@ -330,19 +330,12 @@ export default function ToolConfig({
};
return (
-
-
-
-
-
-
{t('settings.tools.backToAllTools')}
-
+
+
{
- const path = location.pathname;
- if (path.includes('/settings/sources')) return t('settings.sources.label');
- if (path.includes('/settings/analytics'))
- return t('settings.analytics.label');
- if (path.includes('/settings/logs')) return t('settings.logs.label');
- if (path.includes('/settings/tools')) return t('settings.tools.label');
- if (path.includes('/settings/custom-models'))
- return t('settings.customModels.label');
- if (path.includes('/settings/access-tokens'))
- return t('settings.accessTokens.label');
- return t('settings.general.label');
- };
-
- const [activeTab, setActiveTab] = React.useState(getActiveTabFromPath());
- const tabsList = [
- t('settings.general.label'),
- t('settings.sources.label'),
- t('settings.analytics.label'),
- t('settings.logs.label'),
- t('settings.tools.label'),
- t('settings.customModels.label'),
- t('settings.accessTokens.label'),
- ];
- const [hiddenGradient, setHiddenGradient] =
- useState('left');
- const containerRef = useRef(null);
-
- const scrollTabs = useCallback(
- (direction: number) => {
- if (containerRef.current) {
- const container = containerRef.current;
- container.scrollLeft += direction * 100;
- if (container.scrollLeft === 0) {
- setHiddenGradient('left');
- } else if (
- container.scrollLeft + container.offsetWidth ===
- container.scrollWidth
- ) {
- setHiddenGradient('right');
- } else {
- setHiddenGradient(undefined);
- }
- }
- },
- [containerRef],
- );
-
- const handleTabChange = (tab: string) => {
- setActiveTab(tab);
- if (tab === t('settings.general.label')) navigate('/settings');
- else if (tab === t('settings.sources.label')) navigate('/settings/sources');
- else if (tab === t('settings.analytics.label'))
- navigate('/settings/analytics');
- else if (tab === t('settings.logs.label')) navigate('/settings/logs');
- else if (tab === t('settings.tools.label')) navigate('/settings/tools');
- else if (tab === t('settings.customModels.label'))
- navigate('/settings/custom-models');
- else if (tab === t('settings.accessTokens.label'))
- navigate('/settings/access-tokens');
- };
-
- React.useEffect(() => {
- setActiveTab(getActiveTabFromPath());
- }, [location.pathname]);
-
- React.useEffect(() => {
- const newActiveTab = getActiveTabFromPath();
- setActiveTab(newActiveTab);
- }, [i18n.language]);
+ const showIndex =
+ (isMobile || isTablet) && location.pathname === SETTINGS_SECTION.rootPath;
const token = useSelector(selectToken);
const documents = useSelector(selectSourceDocs);
@@ -139,74 +65,38 @@ export default function Settings() {
return (
-
- {t('settings.label')}
-
-
handleTabChange(tab)}
- className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0"
- >
-
-
-
-
scrollTabs(-1)}
- className="hover:bg-muted dark:hover:bg-accent h-6 w-6 rounded-full"
- aria-label={t('settings.scrollTabsLeft')}
- >
-
-
-
-
- {tabsList.map((tab) => (
-
- {tab}
-
- ))}
-
-
-
scrollTabs(1)}
- className="hover:bg-muted dark:hover:bg-accent h-6 w-6 rounded-full"
- aria-label={t('settings.scrollTabsRight')}
- >
-
-
-
-
-
- } />
-
- }
- />
- } />
- } />
- } />
- }
- />
- } />
- } />
- } />
-
+
+ {showIndex ? (
+
+ ) : (
+ <>
+
+
+ } />
+ } />
+
+ }
+ />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+
+ >
+ )}
+
);
}
diff --git a/tests/core/test_model_registry_yaml.py b/tests/core/test_model_registry_yaml.py
index 1015f964..982455bf 100644
--- a/tests/core/test_model_registry_yaml.py
+++ b/tests/core/test_model_registry_yaml.py
@@ -30,11 +30,12 @@ from docsgpt.core.model_yaml import (
# in an upstream model id) that would silently break every agent that
# references the old id.
EXPECTED_IDS = {
- "openai": {"gpt-5.5", "gpt-5.4-mini", "gpt-5.4-nano"},
+ "openai": {"gpt-5.5", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.6-sol"},
"anthropic": {
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5",
+ "claude-fable-5",
},
"google": {
"gemini-3.1-pro-preview",
@@ -59,6 +60,8 @@ EXPECTED_IDS = {
"openai_compatible": {
"deepseek-v4-flash",
"deepseek-v4-pro",
+ "qwen3.8-max",
+ "glm-5.3",
},
"docsgpt": {"docsgpt-local"},
"huggingface": {"huggingface-local"},
@@ -250,7 +253,11 @@ class TestRegistryPermutations:
assert ids == EXPECTED_IDS["docsgpt"]
def test_everything_set(self, monkeypatch):
+ # Every openai_compatible catalog reads its own key from the
+ # environment, so each needs one here for "everything" to mean it.
monkeypatch.setenv("DEEPSEEK_API_KEY", "x")
+ monkeypatch.setenv("DASHSCOPE_API_KEY", "x")
+ monkeypatch.setenv("ZAI_API_KEY", "x")
s = _make_settings(
OPENAI_API_KEY="x",
ANTHROPIC_API_KEY="x",
diff --git a/tests/e2e/specs/tier-b/guardrail-events-panel.spec.ts b/tests/e2e/specs/tier-b/guardrail-events-panel.spec.ts
index 1d7768db..8fc533d6 100644
--- a/tests/e2e/specs/tier-b/guardrail-events-panel.spec.ts
+++ b/tests/e2e/specs/tier-b/guardrail-events-panel.spec.ts
@@ -1,5 +1,5 @@
/**
- * tier-b · the guardrail activity panel on /agents/logs/:agentId.
+ * tier-b · the guardrail activity panel on /agents/manage/logs/:agentId.
*
* Silent break covered: the audit journal existed for a while with no UI and
* no caller — `userService.getGuardrailEvents` had zero references — so a
@@ -121,7 +121,7 @@ test.describe('tier-b · guardrail activity panel', () => {
const { context } = await newUserContext(browser, { sub });
const page = await context.newPage();
- await page.goto(`/agents/logs/${agentId}`);
+ await page.goto(`/agents/manage/logs/${agentId}`);
const panel = page.getByTestId('guardrail-events');
await expect(panel).toBeVisible();
@@ -174,7 +174,7 @@ test.describe('tier-b · guardrail activity panel', () => {
const { context } = await newUserContext(browser, { sub });
const page = await context.newPage();
- await page.goto(`/agents/logs/${agentId}`);
+ await page.goto(`/agents/manage/logs/${agentId}`);
const table = page.getByTestId('guardrail-events-rows');
await expect(table).toContainText('denylist');
@@ -212,7 +212,7 @@ test.describe('tier-b · guardrail activity panel', () => {
const { context } = await newUserContext(browser, { sub });
const page = await context.newPage();
- await page.goto(`/agents/logs/${agentId}`);
+ await page.goto(`/agents/manage/logs/${agentId}`);
await expect(page.getByTestId('guardrail-events-empty')).toBeVisible();
await expect(page.getByTestId('guardrail-stat-blocked')).toContainText(
@@ -253,7 +253,7 @@ test.describe('tier-b · guardrail activity panel', () => {
const { context } = await newUserContext(browser, { sub });
const page = await context.newPage();
- await page.goto(`/agents/logs/${quiet.id}`);
+ await page.goto(`/agents/manage/logs/${quiet.id}`);
await expect(page.getByTestId('guardrail-stat-blocked')).toContainText(
'0',
diff --git a/tests/e2e/specs/tier-b/guardrails.spec.ts b/tests/e2e/specs/tier-b/guardrails.spec.ts
index 93e122cb..5a6114d1 100644
--- a/tests/e2e/specs/tier-b/guardrails.spec.ts
+++ b/tests/e2e/specs/tier-b/guardrails.spec.ts
@@ -731,7 +731,7 @@ test.describe('tier-b · guardrails builder UI', () => {
expect(await dbAgentConfig(agentId)).toEqual({});
const page = await context.newPage();
- await page.goto(`/agents/edit/${agentId}`);
+ await page.goto(`/agents/manage/edit/${agentId}`);
const section = page.getByTestId('guardrails-section');
await expect(section).toBeVisible();
diff --git a/tests/e2e/specs/tier-c/ui-smoke.spec.ts b/tests/e2e/specs/tier-c/ui-smoke.spec.ts
index 6f15d3d0..5da532bb 100644
--- a/tests/e2e/specs/tier-c/ui-smoke.spec.ts
+++ b/tests/e2e/specs/tier-c/ui-smoke.spec.ts
@@ -17,7 +17,7 @@ import * as playwright from '@playwright/test';
* C10 upload drag-drop -> fixme: no global drop handler in current UI
* C11 markdown rendering -> seeded /share conv renders from ```md```
* C12 mermaid rendering -> seeded /share conv renders mermaid (code or svg)
- * C13 agent logs route -> `/agents/logs/:agentId` renders title
+ * C13 agent logs route -> `/agents/manage/logs/:agentId` renders title
*
* Setup: one shared authenticated context per test (no beforeAll reuse because
* some tests mutate localStorage / navigate to routes that break other tests).
@@ -453,7 +453,7 @@ test.describe('tier-c · UI smoke', () => {
}
});
- test('C13 · agents/logs/:agentId renders for a seeded agent', async ({
+ test('C13 · agents/manage/logs/:agentId renders for a seeded agent', async ({
browser,
}) => {
const { context, sub, token } = await newUserContext(browser);
@@ -465,7 +465,7 @@ test.describe('tier-c · UI smoke', () => {
const agentId = await insertStubAgent(sub, 'ui-smoke-agent');
const page = await context.newPage();
- await page.goto(`/agents/logs/${agentId}`);
+ await page.goto(`/agents/manage/logs/${agentId}`);
// AgentPageHeader renders a breadcrumb plus a labelled sub-nav; it has
// no heading element (it used to, before #2495 moved it to shadcn