Skip to content

Commit cd2b053

Browse files
abossardCopilot
andcommitted
Add 'Show in Menu' — agents appear as tabs in navigation
When an agent has show_in_menu=true, it appears as a tab in the main navigation bar. Clicking it opens a dedicated run page with just the input field, run button, and SchemaRenderer output. Backend: - AgentDefinition: new show_in_menu bool field (default false) - AgentDefinitionCreate/Update: show_in_menu parameter - Migration for existing DBs - Service wires it through create/update Frontend: - WorkbenchPage: 'Show in menu' checkbox in create form - App.jsx: fetches agents with show_in_menu=true, injects as tabs - AgentRunPage.jsx: simple standalone run page (title, description, optional input, run button, SchemaRenderer output) - Dynamic routes: /agent-run/{agentId} E2E test: - Creates agent via API with show_in_menu=true - Verifies tab appears in navigation with agent name - Clicks tab, verifies AgentRunPage renders - Runs agent (mocked), verifies output with SchemaRenderer 24 Playwright + 133 backend = 157 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a960c05 commit cd2b053

7 files changed

Lines changed: 253 additions & 0 deletions

File tree

backend/agent_builder/models/agent.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ class AgentDefinition(SQLModel, table=True):
6161
description="JSON Schema for structured output (empty object = no constraint)",
6262
sa_column=Column(String, name="output_schema"),
6363
)
64+
show_in_menu: bool = SField(
65+
default=False,
66+
description="When true, agent appears as a tab in the main navigation",
67+
)
6468
tool_names_json: str = SField(
6569
default="[]",
6670
description="JSON-serialized list of tool names available to this agent",
@@ -132,6 +136,7 @@ def to_dict(self) -> dict[str, Any]:
132136
"max_tokens": self.max_tokens,
133137
"output_instructions": self.output_instructions,
134138
"output_schema": self.output_schema,
139+
"show_in_menu": self.show_in_menu,
135140
"tool_names": self.tool_names,
136141
"success_criteria": [c.model_dump() for c in self.success_criteria],
137142
"created_at": self.created_at.isoformat(),
@@ -156,6 +161,7 @@ class AgentDefinitionCreate(BaseModel):
156161
)
157162
tool_names: list[str] = Field(default_factory=list)
158163
success_criteria: list[SuccessCriteria] = Field(default_factory=list)
164+
show_in_menu: bool = Field(default=False, description="Show agent as a tab in the main navigation")
159165

160166

161167
class AgentDefinitionUpdate(BaseModel):
@@ -172,3 +178,4 @@ class AgentDefinitionUpdate(BaseModel):
172178
output_schema: Optional[dict[str, Any]] = Field(default=None)
173179
tool_names: Optional[list[str]] = Field(default=None)
174180
success_criteria: Optional[list[SuccessCriteria]] = Field(default=None)
181+
show_in_menu: Optional[bool] = Field(default=None)

backend/agent_builder/persistence/database.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def _run_migrations(engine) -> None:
3030
_ensure_column(engine, "workbench_agent_definitions", "max_tokens", "INTEGER NOT NULL DEFAULT 4096")
3131
_ensure_column(engine, "workbench_agent_definitions", "output_instructions", "TEXT NOT NULL DEFAULT ''")
3232
_ensure_column(engine, "workbench_agent_definitions", "output_schema", "TEXT NOT NULL DEFAULT '{}'")
33+
_ensure_column(engine, "workbench_agent_definitions", "show_in_menu", "BOOLEAN NOT NULL DEFAULT 0")
3334

3435
def _ensure_column(engine, table_name: str, column_name: str, column_ddl: str) -> None:
3536
with Session(engine) as session:

backend/agent_builder/service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ def create_agent(self, data: AgentDefinitionCreate) -> AgentDefinition:
356356
recursion_limit=data.recursion_limit,
357357
max_tokens=data.max_tokens,
358358
output_instructions=data.output_instructions,
359+
show_in_menu=data.show_in_menu,
359360
)
360361
agent.tool_names = validated_tool_names
361362
agent.success_criteria = data.success_criteria
@@ -405,6 +406,8 @@ def update_agent(
405406
agent.output_instructions = data.output_instructions
406407
if data.output_schema is not None:
407408
agent.output_schema = data.output_schema
409+
if data.show_in_menu is not None:
410+
agent.show_in_menu = data.show_in_menu
408411
agent.updated_at = datetime.now()
409412
return self._repo.update_agent(agent)
410413

frontend/src/App.jsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,17 @@ import {
2222
Table24Regular,
2323
Wrench24Regular,
2424
} from '@fluentui/react-icons'
25+
import { useEffect, useState } from 'react'
2526
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
2627
import AgentChat from './features/agent/AgentChat'
2728
import CSVTicketTable from './features/csvtickets/CSVTicketTable'
2829
import FieldsDocs from './features/fields/FieldsDocs'
2930
import { USECASE_DEMO_DEFINITIONS } from './features/usecase-demo/demoDefinitions'
3031
import UsecaseDemoPage from './features/usecase-demo/UsecaseDemoPage'
3132
import KitchenSink from './features/kitchensink/KitchenSink'
33+
import AgentRunPage from './features/workbench/AgentRunPage'
3234
import WorkbenchPage from './features/workbench/WorkbenchPage'
35+
import { listWorkbenchAgents } from './services/api'
3336

3437
const useStyles = makeStyles({
3538
app: {
@@ -66,6 +69,17 @@ export default function App() {
6669
const location = useLocation()
6770
const navigate = useNavigate()
6871

72+
const [menuAgents, setMenuAgents] = useState([])
73+
useEffect(() => {
74+
listWorkbenchAgents()
75+
.then((data) => {
76+
const agents = (data.agents || []).filter((a) => a.show_in_menu)
77+
console.debug('[App] Menu agents:', agents.map((a) => a.name))
78+
setMenuAgents(agents)
79+
})
80+
.catch((err) => console.warn('[App] Failed to load menu agents:', err))
81+
}, [])
82+
6983
const usecaseTabs = USECASE_DEMO_DEFINITIONS.filter(
7084
(definition) => definition.showInNav !== false
7185
).map((definition) => ({
@@ -76,9 +90,18 @@ export default function App() {
7690
testId: definition.tabTestId,
7791
}))
7892

93+
const agentMenuTabs = menuAgents.map((agent) => ({
94+
value: `agent-menu-${agent.id}`,
95+
label: agent.name,
96+
icon: <Bot24Regular />,
97+
path: `/agent-run/${agent.id}`,
98+
testId: `tab-agent-menu-${agent.id}`,
99+
}))
100+
79101
const tabs = [
80102
{ value: 'csvtickets', label: 'Tickets', icon: <Table24Regular />, path: '/csvtickets', testId: 'tab-csvtickets' },
81103
...usecaseTabs,
104+
...agentMenuTabs,
82105
{ value: 'kitchensink', label: 'Kitchen Sink', icon: <DataHistogram24Regular />, path: '/kitchensink', testId: 'tab-kitchensink' },
83106
{ value: 'fields', label: 'Fields', icon: <Info24Regular />, path: '/fields', testId: 'tab-fields' },
84107
{ value: 'workbench', label: 'Agent Fabric', icon: <Wrench24Regular />, path: '/workbench', testId: 'tab-workbench' },
@@ -128,6 +151,13 @@ export default function App() {
128151
<Route path="/kitchensink" element={<KitchenSink />} />
129152
<Route path="/fields" element={<FieldsDocs />} />
130153
<Route path="/workbench" element={<WorkbenchPage />} />
154+
{menuAgents.map((agent) => (
155+
<Route
156+
key={agent.id}
157+
path={`/agent-run/${agent.id}`}
158+
element={<AgentRunPage agent={agent} />}
159+
/>
160+
))}
131161
<Route path="/agent" element={<AgentChat />} />
132162
<Route path="*" element={<Navigate to="/csvtickets" replace />} />
133163
</Routes>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* AgentRunPage — Standalone page for running a specific agent.
3+
*
4+
* Shown as a tab when an agent has show_in_menu=true.
5+
* Minimal UI: description, optional input field, run button, output.
6+
*/
7+
8+
import {
9+
Button,
10+
Card,
11+
Field,
12+
Spinner,
13+
Subtitle1,
14+
Text,
15+
Textarea,
16+
makeStyles,
17+
tokens,
18+
} from '@fluentui/react-components'
19+
import { useEffect, useState } from 'react'
20+
import { runWorkbenchAgent } from '../../services/api'
21+
import SchemaRenderer from './SchemaRenderer'
22+
23+
const useStyles = makeStyles({
24+
container: {
25+
padding: tokens.spacingVerticalL,
26+
display: 'flex',
27+
flexDirection: 'column',
28+
gap: tokens.spacingVerticalL,
29+
maxWidth: '800px',
30+
},
31+
outputContainer: {
32+
border: `1px solid ${tokens.colorNeutralStroke1}`,
33+
borderRadius: tokens.borderRadiusMedium,
34+
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
35+
backgroundColor: tokens.colorNeutralBackground1,
36+
maxHeight: '500px',
37+
overflowY: 'auto',
38+
},
39+
})
40+
41+
export default function AgentRunPage({ agent }) {
42+
const styles = useStyles()
43+
const [prompt, setPrompt] = useState('')
44+
const [requiredInput, setRequiredInput] = useState('')
45+
const [running, setRunning] = useState(false)
46+
const [error, setError] = useState('')
47+
const [output, setOutput] = useState(null)
48+
49+
if (!agent) {
50+
return <Spinner label="Loading agent..." />
51+
}
52+
53+
const parsedOutput = (() => {
54+
if (!output) return null
55+
try {
56+
const parsed = JSON.parse(output)
57+
if (typeof parsed === 'object' && parsed !== null) return parsed
58+
} catch { /* not JSON */ }
59+
return { message: output }
60+
})()
61+
62+
const handleRun = async () => {
63+
setError('')
64+
setOutput(null)
65+
66+
if (agent.requires_input && !requiredInput.trim()) {
67+
setError(`Required: ${agent.required_input_description || 'input value'}`)
68+
return
69+
}
70+
71+
setRunning(true)
72+
try {
73+
const run = await runWorkbenchAgent(agent.id, {
74+
inputPrompt: prompt.trim(),
75+
requiredInputValue: requiredInput.trim(),
76+
})
77+
setOutput(run?.output || '(no output)')
78+
} catch (err) {
79+
setError(err?.message || 'Agent run failed')
80+
} finally {
81+
setRunning(false)
82+
}
83+
}
84+
85+
return (
86+
<div className={styles.container}>
87+
<div>
88+
<Subtitle1 data-testid="agent-run-page-title">{agent.name}</Subtitle1>
89+
{agent.description && <Text>{agent.description}</Text>}
90+
</div>
91+
92+
<Card>
93+
{agent.requires_input && (
94+
<Field label={agent.required_input_description || 'Required input'} required>
95+
<Textarea
96+
data-testid="agent-run-required-input"
97+
value={requiredInput}
98+
onChange={(_, d) => setRequiredInput(d.value)}
99+
rows={1}
100+
placeholder={agent.required_input_description}
101+
/>
102+
</Field>
103+
)}
104+
<Field label="Prompt (optional)">
105+
<Textarea
106+
data-testid="agent-run-prompt"
107+
value={prompt}
108+
onChange={(_, d) => setPrompt(d.value)}
109+
rows={2}
110+
placeholder="Additional instructions..."
111+
/>
112+
</Field>
113+
<Button
114+
appearance="primary"
115+
data-testid="agent-run-button"
116+
onClick={handleRun}
117+
disabled={running}
118+
style={{ marginTop: tokens.spacingVerticalS }}
119+
>
120+
{running ? 'Running...' : 'Run'}
121+
</Button>
122+
{error && <Text style={{ color: tokens.colorPaletteRedForeground1 }}>{error}</Text>}
123+
</Card>
124+
125+
{parsedOutput && (
126+
<div data-testid="agent-run-output" className={styles.outputContainer}>
127+
<SchemaRenderer
128+
data={parsedOutput}
129+
schema={agent.output_schema?.properties ? agent.output_schema : undefined}
130+
/>
131+
</div>
132+
)}
133+
</div>
134+
)
135+
}

frontend/src/features/workbench/WorkbenchPage.jsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export default function WorkbenchPage() {
110110
systemPrompt: '',
111111
requiresInput: false,
112112
requiredInputDescription: '',
113+
showInMenu: false,
113114
})
114115
const [fieldErrors, setFieldErrors] = useState({
115116
name: '',
@@ -270,6 +271,7 @@ export default function WorkbenchPage() {
270271
tool_names: selectedToolNames,
271272
output_schema: parsedSchema,
272273
success_criteria: [],
274+
show_in_menu: formData.showInMenu,
273275
})
274276

275277
const agentsPayload = await listWorkbenchAgents()
@@ -280,6 +282,7 @@ export default function WorkbenchPage() {
280282
systemPrompt: '',
281283
requiresInput: false,
282284
requiredInputDescription: '',
285+
showInMenu: false,
283286
})
284287
setOutputSchema('')
285288
setRunForm((prev) => ({
@@ -454,6 +457,12 @@ export default function WorkbenchPage() {
454457
setFieldErrors((prev) => ({ ...prev, requiredInputDescription: '' }))
455458
}}
456459
/>
460+
<Checkbox
461+
data-testid="workbench-agent-show-in-menu-checkbox"
462+
label="Show in menu"
463+
checked={formData.showInMenu}
464+
onChange={(_, data) => setFormData((prev) => ({ ...prev, showInMenu: Boolean(data.checked) }))}
465+
/>
457466
{formData.requiresInput && (
458467
<>
459468
<Field label="Input description" required>

tests/e2e/workbench.spec.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,3 +649,71 @@ test.describe("SchemaRenderer widgets", () => {
649649
await createdRow.getByRole("button", { name: "Delete" }).click();
650650
});
651651
});
652+
653+
test.describe("Show in Menu", () => {
654+
test("agent with show_in_menu appears as a tab and runs from its own page", async ({ page }) => {
655+
const agentName = `e2e-menu-agent-${Date.now()}`;
656+
const backendUrl = APP_URL.replace("3001", "5001");
657+
658+
// Create an agent with show_in_menu=true via API
659+
const createResp = await page.request.post(`${backendUrl}/api/workbench/agents`, {
660+
data: {
661+
name: agentName,
662+
description: "A menu agent for E2E testing",
663+
system_prompt: "Use csv_ticket_stats and report the total.",
664+
tool_names: ["csv_ticket_stats"],
665+
show_in_menu: true,
666+
},
667+
});
668+
const createdAgent = await createResp.json();
669+
const agentId = createdAgent.id;
670+
671+
// Mock the run endpoint for this agent
672+
await page.route("**/api/workbench/agents/*/runs", async (route) => {
673+
const body = route.request().postDataJSON();
674+
await route.fulfill({
675+
status: 200,
676+
contentType: "application/json",
677+
body: JSON.stringify({
678+
id: "run-menu-1",
679+
agent_id: agentId,
680+
input_prompt: body?.input_prompt || "",
681+
status: "completed",
682+
output: JSON.stringify({
683+
message: "## Stats Report\n\nTotal: 206 tickets.",
684+
referenced_tickets: [],
685+
}, null, 2),
686+
agent_snapshot: { tool_names: ["csv_ticket_stats"] },
687+
tools_used: ["csv_ticket_stats"],
688+
error: null,
689+
created_at: "2026-03-04T10:00:00Z",
690+
completed_at: "2026-03-04T10:00:01Z",
691+
}),
692+
});
693+
});
694+
695+
// Load the app — the agent should appear as a tab
696+
await page.goto(`${APP_URL}/csvtickets`, { waitUntil: "load" });
697+
698+
// Find the menu tab for our agent
699+
const agentTab = page.getByTestId(`tab-agent-menu-${agentId}`);
700+
await expect(agentTab).toBeVisible({ timeout: 10000 });
701+
await expect(agentTab).toContainText(agentName);
702+
703+
// Click the tab — navigates to the agent run page
704+
await agentTab.click();
705+
await expect(page.getByTestId("agent-run-page-title")).toContainText(agentName);
706+
await expect(page.getByText("A menu agent for E2E testing")).toBeVisible();
707+
708+
// Run the agent from its own page
709+
await page.getByTestId("agent-run-button").click();
710+
711+
// Verify output renders
712+
const output = page.getByTestId("agent-run-output");
713+
await expect(output).toBeVisible({ timeout: 10000 });
714+
await expect(output.getByRole("heading", { name: "Stats Report" })).toBeVisible();
715+
716+
// Clean up — delete via API
717+
await page.request.delete(`${backendUrl}/api/workbench/agents/${agentId}`);
718+
});
719+
});

0 commit comments

Comments
 (0)