From 095525f2d0ea68c07f0432bed5f4b4048360327b Mon Sep 17 00:00:00 2001 From: prasannakumar-tavro Date: Fri, 12 Jun 2026 15:07:53 +0530 Subject: [PATCH 1/3] Home dashboard dynamic view instead of static values --- run_connector.py => run_connectors.py | 0 tavro_api/api/routers/insights.py | 248 ++++++++++++++++++++++- tavro_app/src/components/AgentHeader.tsx | 1 - tavro_app/src/components/Layout.tsx | 32 ++- tavro_app/src/pages/HomePage.tsx | 227 ++++++++++++++------- tavro_app/src/pages/SparkPage.tsx | 6 + tavro_app/src/pages/UseCaseViewPage.tsx | 5 +- tavro_app/src/services/agentApi.ts | 36 +++- tavro_app/src/services/insightsApi.ts | 23 +++ tavro_app/src/services/portalActivity.ts | 66 ++++++ tavro_app/src/services/sparkApi.ts | 4 + tavro_app/src/services/useCaseApi.ts | 29 ++- 12 files changed, 591 insertions(+), 86 deletions(-) rename run_connector.py => run_connectors.py (100%) create mode 100644 tavro_app/src/services/portalActivity.ts diff --git a/run_connector.py b/run_connectors.py similarity index 100% rename from run_connector.py rename to run_connectors.py diff --git a/tavro_api/api/routers/insights.py b/tavro_api/api/routers/insights.py index e790d3a..9724f54 100644 --- a/tavro_api/api/routers/insights.py +++ b/tavro_api/api/routers/insights.py @@ -328,6 +328,46 @@ def _days_since(ts: Any) -> int: return 0 +def _to_dt(ts: Any) -> Optional[datetime]: + if not ts: + return None + try: + dt = ts if isinstance(ts, datetime) else datetime.fromisoformat(str(ts)) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except (TypeError, ValueError): + return None + + +def _relative_time(ts: Any) -> str: + dt = _to_dt(ts) + if not dt: + return "Recently" + seconds = max(0, int((datetime.now(timezone.utc) - dt).total_seconds())) + if seconds < 60: + return "Just now" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + days = hours // 24 + if days == 1: + return "Yesterday" + if days < 7: + return f"{days} days ago" + weeks = days // 7 + if weeks < 5: + return f"{weeks}w ago" + months = days // 30 + if months < 12: + return f"{months}mo ago" + years = days // 365 + return f"{years}y ago" + + # KPI catalog — port of KPI_DEFINITIONS. Each returns (value, target, status). def _kpi_task_completion(perf): # noqa: ANN001 v = 82 + perf * 16 @@ -418,6 +458,8 @@ def _pct(count: int, total: int) -> int: a.agent_name, a.agent_description, a.source_system, + a.created_ts, + a.updated_ts, i.environment, i.governance_status, cfg.autonomy_level, @@ -477,11 +519,27 @@ def _pct(count: int, total: int) -> int: """ _USECASES_SQL = f""" -SELECT name, status +SELECT ai_use_case_id, name, status, created_ts, updated_ts FROM {CORE}.ai_use_cases WHERE (tenant_id = :tid OR tenant_id IS NULL) """ +_SPARK_COUNTS_SQL = f""" +SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days')::int AS this_week +FROM {CORE}.spark_ideas +WHERE company_id = :cid +""" + +_RECENT_SPARK_SQL = f""" +SELECT idea_id, title, created_at, updated_at +FROM {CORE}.spark_ideas +WHERE company_id = :cid +ORDER BY COALESCE(updated_at, created_at) DESC NULLS LAST +LIMIT :limit +""" + _COMPANY_PICK_SQL = """ SELECT id FROM twin.company ORDER BY updated_at DESC NULLS LAST LIMIT 1 """ @@ -614,6 +672,15 @@ def _to_risk_agent(a: Dict[str, Any]) -> Dict[str, Any]: for a in agent_rows if _risk_not_triggered(a) ] + spark_total, spark_this_week = await _spark_counts(db, company_id) + use_cases_in_progress = sum( + 1 + for uc in uc_rows + if any(k in _norm(uc.get("status")) for k in ("progress", "build", "develop")) + ) + live_agents = sum(1 for a in agent_rows if _is_prod_env(a.get("environment"))) + need_review = sum(1 for a in agent_rows if _needs_human(a)) + # --- stage gate blockers (every agent) --- stage_gate_blockers = [ { @@ -647,14 +714,22 @@ def _to_risk_agent(a: Dict[str, Any]) -> Dict[str, Any]: # --- company profile (twin) --- company_profile = await _build_company_profile(db, company_id) + recent_activity = await _home_recent_activity(db, company_id, agent_rows, uc_rows) + attention_items = _home_attention_items(agent_rows, uc_rows, company_profile) return { "totals": { + "sparkIdeas": spark_total, + "sparkIdeasThisWeek": spark_this_week, "totalAgents": total_agents, + "liveAgents": live_agents, "totalUseCases": len(uc_rows), + "useCasesInProgress": use_cases_in_progress, "criticalCount": sum(1 for a in agent_rows if a["_risk"] == "critical"), "highRiskCount": sum(1 for a in agent_rows if a["_risk"] == "high"), "hitlOpen": len(hitl), + "openIssues": len(hitl), + "needReview": need_review, }, "agentLifecycle": agent_lifecycle, "useCaseLifecycle": usecase_lifecycle, @@ -667,9 +742,180 @@ def _to_risk_agent(a: Dict[str, Any]) -> Dict[str, Any]: "stageGateBlockers": stage_gate_blockers, "successMetrics": success_metrics, "companyProfile": company_profile, + "homeRecentActivity": recent_activity, + "homeAttentionItems": attention_items, } +async def _spark_counts(db: AsyncSession, company_id: Optional[str]) -> tuple[int, int]: + try: + cid = company_id + if not cid: + row = (await db.execute(text(_COMPANY_PICK_SQL))).first() + cid = str(row[0]) if row else None + if not cid: + return 0, 0 + row = (await db.execute(text(_SPARK_COUNTS_SQL), {"cid": cid})).mappings().first() + if not row: + return 0, 0 + return int(row["total"] or 0), int(row["this_week"] or 0) + except Exception: # noqa: BLE001 + return 0, 0 + + +async def _resolve_company_id(db: AsyncSession, company_id: Optional[str]) -> Optional[str]: + if company_id: + return company_id + row = (await db.execute(text(_COMPANY_PICK_SQL))).first() + return str(row[0]) if row else None + + +async def _home_recent_activity( + db: AsyncSession, + company_id: Optional[str], + agent_rows: List[Dict[str, Any]], + uc_rows: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + events: List[Dict[str, Any]] = [] + + try: + cid = await _resolve_company_id(db, company_id) + if cid: + spark_rows = (await db.execute(text(_RECENT_SPARK_SQL), {"cid": cid, "limit": 4})).mappings().all() + for row in spark_rows: + ts = row.get("updated_at") or row.get("created_at") + title = _display(row.get("title"), "Spark idea") + events.append({ + "id": f"spark:{row.get('idea_id')}", + "text": f"Spark idea added: {title}", + "time": _relative_time(ts), + "dot": "emerald", + "_ts": _to_dt(ts), + }) + except Exception: # noqa: BLE001 + pass + + for uc in uc_rows: + ts = uc.get("updated_ts") or uc.get("created_ts") + status = _display(uc.get("status"), "") + name = _display(uc.get("name"), "AI use case") + if status: + text_value = f"{name} moved to {status} stage" + else: + text_value = f"AI use case updated: {name}" + events.append({ + "id": f"usecase:{uc.get('ai_use_case_id')}", + "text": text_value, + "time": _relative_time(ts), + "dot": "violet", + "_ts": _to_dt(ts), + }) + + for agent in agent_rows: + risk_ts = agent.get("assessment_ts") + if risk_ts and _has_resolved_risk(agent): + risk = _display(agent.get("blended_risk_class") or agent.get("aivss_class"), "risk") + events.append({ + "id": f"agent-risk:{agent.get('agent_id')}", + "text": f"{_display(agent.get('agent_name'), 'Agent')} risk classified as {risk}", + "time": _relative_time(risk_ts), + "dot": "amber" if agent.get("_risk") in ("critical", "high", "medium") else "emerald", + "_ts": _to_dt(risk_ts), + }) + + ts = agent.get("updated_ts") or agent.get("created_ts") + if ts: + events.append({ + "id": f"agent:{agent.get('agent_id')}", + "text": f"Agent updated: {_display(agent.get('agent_name'), 'Untitled agent')}", + "time": _relative_time(ts), + "dot": "emerald" if _is_prod_env(agent.get("environment")) else "violet", + "_ts": _to_dt(ts), + }) + + events.sort(key=lambda e: e.get("_ts") or datetime.min.replace(tzinfo=timezone.utc), reverse=True) + return [{k: v for k, v in event.items() if k != "_ts"} for event in events[:4]] + + +def _home_attention_items( + agent_rows: List[Dict[str, Any]], + uc_rows: List[Dict[str, Any]], + company_profile: Dict[str, Any], +) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + + review_agents = sorted( + [a for a in agent_rows if _needs_human(a)], + key=lambda a: _to_dt(a.get("updated_ts")) or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + for agent in review_agents: + status = _display(agent.get("governance_status") or agent.get("risk_state"), "review required") + items.append({ + "id": f"agent-review:{agent.get('agent_id')}", + "badge": "Approval", + "text": f"{_display(agent.get('agent_name'), 'Agent')} - {status}", + "action": "Review", + "route": f"/agent/{agent.get('agent_id')}", + }) + + risk_agents = sorted( + [a for a in agent_rows if a.get("_risk") in ("critical", "high")], + key=lambda a: a.get("_score") or 0, + reverse=True, + ) + for agent in risk_agents: + items.append({ + "id": f"agent-risk:{agent.get('agent_id')}", + "badge": "Risk", + "text": f"{_display(agent.get('agent_name'), 'Agent')} - {_display(agent.get('_risk'), 'risk')} risk requires review", + "action": "Review", + "route": f"/agent/{agent.get('agent_id')}", + }) + + for agent in [a for a in agent_rows if _risk_not_triggered(a)]: + items.append({ + "id": f"agent-unassessed:{agent.get('agent_id')}", + "badge": "Issue", + "text": f"{_display(agent.get('agent_name'), 'Agent')} - risk assessment not yet triggered", + "action": "Review", + "route": f"/agent/{agent.get('agent_id')}", + }) + + for uc in uc_rows: + status = _norm(uc.get("status")) + if any(k in status for k in ("pending", "review", "approval", "approve")): + items.append({ + "id": f"usecase-review:{uc.get('ai_use_case_id')}", + "badge": "Approval", + "text": f"{_display(uc.get('name'), 'AI use case')} - {uc.get('status') or 'review pending'}", + "action": "Review", + "route": f"/use-case/{uc.get('ai_use_case_id')}", + }) + + for gap in company_profile.get("gaps", [])[:2]: + area = _display(gap.get("area"), "Profile") + items.append({ + "id": f"blueprint-gap:{gap.get('id')}", + "badge": "Incomplete", + "text": f"Blueprint - {area} section not yet populated", + "action": "Complete", + "route": "/blueprint", + }) + + seen: set[str] = set() + unique: List[Dict[str, Any]] = [] + for item in items: + item_id = str(item.get("id") or item.get("text")) + if item_id in seen: + continue + seen.add(item_id) + unique.append(item) + if len(unique) >= 4: + break + return unique + + async def _build_company_profile(db: AsyncSession, company_id: Optional[str]) -> Dict[str, Any]: empty = {"hasActiveCompany": False, "overallPct": 0, "sections": [], "gaps": [], "refreshes": []} try: diff --git a/tavro_app/src/components/AgentHeader.tsx b/tavro_app/src/components/AgentHeader.tsx index 90e97ae..f13d6fa 100644 --- a/tavro_app/src/components/AgentHeader.tsx +++ b/tavro_app/src/components/AgentHeader.tsx @@ -228,7 +228,6 @@ const AgentHeader: React.FC = ({ - ); }; diff --git a/tavro_app/src/components/Layout.tsx b/tavro_app/src/components/Layout.tsx index e421adb..0e47680 100644 --- a/tavro_app/src/components/Layout.tsx +++ b/tavro_app/src/components/Layout.tsx @@ -17,6 +17,7 @@ import { useUseCases } from '../context/UseCaseContext'; import { useBlueprint } from '../context/BlueprintContext'; import { businessRelationsApi } from '../services/businessRelationsApi'; import { aiModelApi } from '../services/aiModelApi'; +import { portalActivity } from '../services/portalActivity'; const TAVRO_VERSION = 'v.3.1'; import { mcpClient } from '../services/mcpClient'; import { clearAllSessions } from '../store/chatSessionStore'; @@ -81,6 +82,35 @@ const Layout: React.FC = () => { return () => window.removeEventListener('tavro:catalog-item-changed', fetchCatalogCounts); }, [fetchCatalogCounts]); + useEffect(() => { + const useCaseCreated = (event: Event) => { + const detail = (event as CustomEvent).detail ?? {}; + const name = detail.name || detail.title || detail.use_case_name || detail.use_case_id || 'AI use case'; + portalActivity.record(`Created AI use case: ${name}`, 'emerald'); + }; + const agentCreated = (event: Event) => { + const detail = (event as CustomEvent).detail ?? {}; + const agent = detail.agent ?? detail; + const name = agent.agent_name || agent.name || agent.agent_id || 'agent'; + portalActivity.record(`Created agent: ${name}`, 'emerald'); + }; + const agentArtifactsGenerated = (event: Event) => { + const detail = (event as CustomEvent).detail ?? {}; + const agent = detail.agent ?? detail; + const name = agent.agent_name || agent.name || agent.agent_id || 'agent'; + portalActivity.record(`Generated artifacts for ${name}`, 'amber'); + }; + + window.addEventListener('tavro:usecase-created', useCaseCreated); + window.addEventListener('tavro:agent-created', agentCreated); + window.addEventListener('tavro:agent-artifacts-generated', agentArtifactsGenerated); + return () => { + window.removeEventListener('tavro:usecase-created', useCaseCreated); + window.removeEventListener('tavro:agent-created', agentCreated); + window.removeEventListener('tavro:agent-artifacts-generated', agentArtifactsGenerated); + }; + }, []); + // ── Right panel state ──────────────────────────────────────────────────── const [activePanel, setActivePanel] = useState(null); const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH); @@ -588,4 +618,4 @@ const Layout: React.FC = () => { ); }; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/tavro_app/src/pages/HomePage.tsx b/tavro_app/src/pages/HomePage.tsx index 539fde5..549385d 100644 --- a/tavro_app/src/pages/HomePage.tsx +++ b/tavro_app/src/pages/HomePage.tsx @@ -1,10 +1,17 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ClipboardList, Map, Layers, Cpu, Zap, Activity, ArrowRight, TrendingUp, AlertCircle, Bot, X, ArrowUpRight, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useChatContext } from '../context/ChatContext'; +import { useBlueprint } from '../context/BlueprintContext'; +import { + insightsApi, + type HomeAttentionItem, + type InsightsTotals, +} from '../services/insightsApi'; +import { portalActivity, type PortalActivityItem } from '../services/portalActivity'; import travoLogo from '../assets/travo_logo.png'; const STAGES = [ @@ -16,38 +23,34 @@ const STAGES = [ { id: 'govern', label: 'Govern', Icon: Activity, route: '/compliance' }, ]; -const STATS = [ +const STAT_CARDS = [ { + key: 'sparkIdeas', label: 'Spark Ideas', - value: 84, - sub: '+12 this week', subColor: 'text-emerald-600 dark:text-emerald-400', Icon: Zap, iconColor: 'text-violet-600 dark:text-violet-400', iconBg: 'bg-violet-50 dark:bg-violet-900/20', }, { + key: 'useCases', label: 'AI Use Cases', - value: 52, - sub: '8 in progress', subColor: 'text-slate-500 dark:text-slate-400', Icon: ClipboardList, iconColor: 'text-blue-600 dark:text-blue-400', iconBg: 'bg-blue-50 dark:bg-blue-900/20', }, { + key: 'agents', label: 'Active Agents', - value: 84, - sub: '6 live in prod', subColor: 'text-emerald-600 dark:text-emerald-400', Icon: Bot, iconColor: 'text-purple-600 dark:text-purple-400', iconBg: 'bg-purple-50 dark:bg-purple-900/20', }, { + key: 'issues', label: 'Open Issues', - value: 3, - sub: '2 need review', subColor: 'text-rose-600 dark:text-rose-400', Icon: AlertCircle, iconColor: 'text-rose-600 dark:text-rose-400', @@ -55,57 +58,121 @@ const STATS = [ }, ]; -const RECENT_ACTIVITY = [ - { id: 1, text: 'Invoice automation moved to Design stage', time: '2h ago', dot: 'bg-violet-500' }, - { id: 2, text: '3 new Spark ideas generated', time: '5h ago', dot: 'bg-emerald-500' }, - { id: 3, text: 'HR onboarding agent - risk flag raised', time: 'Yesterday', dot: 'bg-amber-500' }, - { id: 4, text: 'Procurement agent deployed to production', time: '2 days ago', dot: 'bg-emerald-500' }, -]; +const EMPTY_TOTALS: InsightsTotals = { + sparkIdeas: 0, + sparkIdeasThisWeek: 0, + totalAgents: 0, + liveAgents: 0, + totalUseCases: 0, + useCasesInProgress: 0, + criticalCount: 0, + highRiskCount: 0, + hitlOpen: 0, + openIssues: 0, + needReview: 0, +}; -const ATTENTION_ITEMS = [ - { - id: 1, - badge: 'Risk', - badgeClass: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', - text: 'HR onboarding agent - autonomy level review required', - action: 'Review', - route: '/catalog', - }, - { - id: 2, - badge: 'Approval', - badgeClass: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400', - text: 'Invoice automation - stakeholder sign-off pending', - action: 'Review', - route: '/use-cases', - }, - { - id: 3, - badge: 'Issue', - badgeClass: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400', - text: 'Procurement agent - behavioral drift detected', - action: 'Review', - route: '/catalog', - }, - { - id: 4, - badge: 'Incomplete', - badgeClass: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400', - text: 'Blueprint - financials section not yet populated', - action: 'Complete', - route: '/blueprint', - }, -]; +const plural = (count: number, singular: string, pluralLabel = `${singular}s`) => + `${count} ${count === 1 ? singular : pluralLabel}`; + +const dotClass = (dot: string) => { + const map: Record = { + violet: 'bg-violet-500', + emerald: 'bg-emerald-500', + amber: 'bg-amber-500', + }; + return map[dot] ?? 'bg-slate-400'; +}; + +const badgeClass = (badge: string) => { + const map: Record = { + Risk: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + Approval: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400', + Issue: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400', + Incomplete: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400', + }; + return map[badge] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'; +}; const HomePage: React.FC = () => { const navigate = useNavigate(); const { setViewContext } = useChatContext(); + const { activeCompany } = useBlueprint(); const [showDeployMessage, setShowDeployMessage] = useState(false); + const [totals, setTotals] = useState(EMPTY_TOTALS); + const [recentActivity, setRecentActivity] = useState(() => portalActivity.list(4)); + const [attentionItems, setAttentionItems] = useState([]); + const [statsLoading, setStatsLoading] = useState(true); useEffect(() => { setViewContext('home'); }, [setViewContext]); + const loadStats = useCallback(async () => { + setStatsLoading(true); + try { + const companyId = activeCompany?.id ?? localStorage.getItem('tavro_active_company_id') ?? undefined; + const summary = await insightsApi.getSummary(companyId); + setTotals({ ...EMPTY_TOTALS, ...summary.totals }); + setAttentionItems(summary.homeAttentionItems ?? []); + } catch (err) { + console.warn('[HomePage] Failed to load dashboard metrics:', err); + setTotals(EMPTY_TOTALS); + setAttentionItems([]); + } finally { + setStatsLoading(false); + } + }, [activeCompany?.id]); + + useEffect(() => { + loadStats(); + }, [loadStats]); + + useEffect(() => { + const syncActivity = () => setRecentActivity(portalActivity.list(4)); + syncActivity(); + window.addEventListener('tavro:portal-activity-changed', syncActivity); + const timer = window.setInterval(syncActivity, 60_000); + return () => { + window.removeEventListener('tavro:portal-activity-changed', syncActivity); + window.clearInterval(timer); + }; + }, []); + + const stats = useMemo(() => { + const openIssues = totals.openIssues ?? totals.hitlOpen; + return STAT_CARDS.map(card => { + if (card.key === 'sparkIdeas') { + return { + ...card, + value: totals.sparkIdeas, + sub: totals.sparkIdeasThisWeek > 0 + ? `+${totals.sparkIdeasThisWeek} this week` + : '0 this week', + }; + } + if (card.key === 'useCases') { + return { + ...card, + value: totals.totalUseCases, + sub: plural(totals.useCasesInProgress, 'in progress', 'in progress'), + }; + } + if (card.key === 'agents') { + return { + ...card, + value: totals.totalAgents, + sub: `${totals.liveAgents} live in prod`, + }; + } + return { + ...card, + value: openIssues, + sub: `${totals.needReview} need review`, + }; + }); + }, [totals]); + const handleStageClick = (id: string, route: string | null) => { if (id === 'deploy') { setShowDeployMessage(true); @@ -136,7 +203,7 @@ const HomePage: React.FC = () => { {/* ── Stats ── */}
- {STATS.map(({ label, value, sub, subColor, Icon, iconColor, iconBg }) => ( + {stats.map(({ label, value, sub, subColor, Icon, iconColor, iconBg }) => (
{
-

{value}

+

+ {statsLoading ? '...' : value} +

{sub}

))} @@ -206,13 +275,19 @@ const HomePage: React.FC = () => { Recent Activity
- {RECENT_ACTIVITY.map(({ id, text, time, dot }) => ( -
- -

{text}

- {time} -
- ))} + {recentActivity.length > 0 ? ( + recentActivity.map(({ id, text, timestamp, dot }) => ( +
+ +

{text}

+ + {portalActivity.formatTime(timestamp)} + +
+ )) + ) : ( +

No recent activity yet

+ )}
@@ -222,20 +297,26 @@ const HomePage: React.FC = () => { Needs Your Attention
- {ATTENTION_ITEMS.map(({ id, badge, badgeClass, text, action, route }) => ( -
- - {badge} - -

{text}

- -
- ))} + {statsLoading && attentionItems.length === 0 ? ( +

Loading attention items...

+ ) : attentionItems.length > 0 ? ( + attentionItems.map(({ id, badge, text, action, route }) => ( +
+ + {badge} + +

{text}

+ +
+ )) + ) : ( +

Nothing needs attention right now

+ )}
diff --git a/tavro_app/src/pages/SparkPage.tsx b/tavro_app/src/pages/SparkPage.tsx index c1b4783..dfd3c50 100644 --- a/tavro_app/src/pages/SparkPage.tsx +++ b/tavro_app/src/pages/SparkPage.tsx @@ -27,6 +27,7 @@ import { import { useBlueprint } from '../context/BlueprintContext'; import { sparkApi } from '../services/sparkApi'; import { mcpClient } from '../services/mcpClient'; +import { portalActivity } from '../services/portalActivity'; import type { SparkIdea } from '../types/spark'; import { SPARK_DIMENSIONS, @@ -623,10 +624,15 @@ const SparkPage: React.FC = () => { setSearch(''); try { const dims = activeDimensions.size > 0 ? [...activeDimensions] : undefined; + let generatedCount = 0; for await (const idea of sparkApi.generateIdeasStream(companyId, dims, direction.trim() || undefined, ideaCount)) { + generatedCount += 1; setIdeas(prev => prev.some(i => i.idea_id === idea.idea_id) ? prev : [...prev, idea]); setHasLibrary(true); } + if (generatedCount > 0) { + portalActivity.record(`Generated ${generatedCount} Spark idea${generatedCount === 1 ? '' : 's'}`, 'emerald'); + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to generate ideas'); } finally { diff --git a/tavro_app/src/pages/UseCaseViewPage.tsx b/tavro_app/src/pages/UseCaseViewPage.tsx index 7d2b942..2f9c285 100644 --- a/tavro_app/src/pages/UseCaseViewPage.tsx +++ b/tavro_app/src/pages/UseCaseViewPage.tsx @@ -1370,6 +1370,7 @@ const UseCaseViewPage: React.FC = () => { setEditError(null); try { await useCaseApi.updateUseCase(id, { + __activityName: (useCase as any).name ?? (useCase as any).title ?? id, title: editTitle.trim() || undefined, description: editDescription.trim() || undefined, priority: editPriority || undefined, @@ -1406,7 +1407,9 @@ const UseCaseViewPage: React.FC = () => { const { field, value } = inlineEdit; setInlineSaving(field); try { - const payload: any = {}; + const payload: any = { + __activityName: (useCase as any)?.name ?? (useCase as any)?.title ?? id, + }; if (field === 'title') payload.title = value.trim(); else if (field === 'description') payload.description = value.trim(); else if (field === 'priority') payload.priority = value; diff --git a/tavro_app/src/services/agentApi.ts b/tavro_app/src/services/agentApi.ts index 0dacd07..b5773ac 100644 --- a/tavro_app/src/services/agentApi.ts +++ b/tavro_app/src/services/agentApi.ts @@ -1,4 +1,5 @@ import { getValidToken } from './auth'; +import { portalActivity } from './portalActivity'; const BASE = (import.meta as any).env?.VITE_TWIN_API_URL ?? ''; const V1 = `${BASE}/api/v1`; @@ -98,6 +99,15 @@ export interface RiskWorkflowStatus { updated_at: string; } +function changedAgentFields(payload: AgentUpdatePayload): string { + const fields: string[] = []; + if (payload.agent_name !== undefined) fields.push('name'); + if (payload.description !== undefined) fields.push('description'); + if (payload.instruction !== undefined) fields.push('instruction'); + if (payload.skills !== undefined) fields.push('skills'); + return fields.length > 0 ? fields.join(', ') : 'details'; +} + class AgentApiService { async getAgentCatalog(startRecord = 1, recordRange = '1-50'): Promise { const params = new URLSearchParams({ start_record: String(startRecord), record_range: recordRange }); @@ -109,10 +119,12 @@ class AgentApiService { } async createAgent(payload: AgentCreatePayload): Promise<{ agent_id: string; agent_name: string; message: string }> { - return req('/agents/', { + const result = await req<{ agent_id: string; agent_name: string; message: string }>('/agents/', { method: 'POST', body: JSON.stringify(payload), }); + portalActivity.record(`Created agent: ${result.agent_name || payload.agent_name}`, 'emerald'); + return result; } async suggestDescription(agentName: string): Promise<{ description: string }> { @@ -123,22 +135,28 @@ class AgentApiService { } async updateAgent(agentId: string, payload: AgentUpdatePayload): Promise<{ message: string; agent_id: string }> { - return req(`/agents/${encodeURIComponent(agentId)}`, { + const result = await req<{ message: string; agent_id: string }>(`/agents/${encodeURIComponent(agentId)}`, { method: 'PUT', body: JSON.stringify(payload), }); + portalActivity.record(`Updated agent ${payload.agent_name || agentId}: ${changedAgentFields(payload)}`, 'violet'); + return result; } async deleteAgent(agentId: string): Promise<{ message: string; agent_id: string }> { - return req(`/agents/${encodeURIComponent(agentId)}`, { + const result = await req<{ message: string; agent_id: string }>(`/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE', }); + portalActivity.record(`Deleted agent: ${agentId}`, 'amber'); + return result; } async triggerRiskAssessment(agentId: string): Promise<{ message: string; agent_id: string; agent_internal_id: string }> { - return req(`/agents/${encodeURIComponent(agentId)}/risk-assessment`, { + const result = await req<{ message: string; agent_id: string; agent_internal_id: string }>(`/agents/${encodeURIComponent(agentId)}/risk-assessment`, { method: 'POST', }); + portalActivity.record(`Triggered risk assessment for agent: ${agentId}`, 'amber'); + return result; } async uploadAgents(files: File[]): Promise<{ @@ -151,7 +169,15 @@ class AgentApiService { for (const file of files) { formData.append('files', file, file.name); } - return reqFormData('/agents/upload', formData); + const result = await reqFormData<{ + uploaded_count: number; + total_submitted: number; + file_results: Array<{ filename: string; valid_count: number; invalid_count: number; errors: string[] }>; + message: string; + }>('/agents/upload', formData); + const fileLabel = files.length === 1 ? ` from ${files[0].name}` : ` from ${files.length} files`; + portalActivity.record(`Uploaded ${result.uploaded_count} agent${result.uploaded_count === 1 ? '' : 's'}${fileLabel}`, 'emerald'); + return result; } async getRiskWorkflows(params?: { status?: string; agentId?: string }): Promise { diff --git a/tavro_app/src/services/insightsApi.ts b/tavro_app/src/services/insightsApi.ts index dfd4684..fc5af4f 100644 --- a/tavro_app/src/services/insightsApi.ts +++ b/tavro_app/src/services/insightsApi.ts @@ -25,11 +25,17 @@ async function req(path: string, init: RequestInit = {}): Promise { // ── Response shape (mirrors tavro_api/api/routers/insights.py) ──────────────── export interface InsightsTotals { + sparkIdeas: number; + sparkIdeasThisWeek: number; totalAgents: number; + liveAgents: number; totalUseCases: number; + useCasesInProgress: number; criticalCount: number; highRiskCount: number; hitlOpen: number; + openIssues: number; + needReview: number; } export interface StageCount { @@ -110,6 +116,21 @@ export interface InsightsCompanyProfile { refreshes: InsightsProfileRefresh[]; } +export interface HomeRecentActivityItem { + id: string; + text: string; + time: string; + dot: 'violet' | 'emerald' | 'amber' | string; +} + +export interface HomeAttentionItem { + id: string; + badge: 'Risk' | 'Approval' | 'Issue' | 'Incomplete' | string; + text: string; + action: string; + route: string; +} + export interface InsightsSummary { totals: InsightsTotals; agentLifecycle: StageCount[]; @@ -123,6 +144,8 @@ export interface InsightsSummary { stageGateBlockers: InsightsGateItem[]; successMetrics: InsightsSuccessMetric[]; companyProfile: InsightsCompanyProfile; + homeRecentActivity?: HomeRecentActivityItem[]; + homeAttentionItems?: HomeAttentionItem[]; } export const insightsApi = { diff --git a/tavro_app/src/services/portalActivity.ts b/tavro_app/src/services/portalActivity.ts new file mode 100644 index 0000000..04921e6 --- /dev/null +++ b/tavro_app/src/services/portalActivity.ts @@ -0,0 +1,66 @@ +export interface PortalActivityItem { + id: string; + text: string; + timestamp: number; + dot: 'violet' | 'emerald' | 'amber'; +} + +const STORAGE_KEY = 'tavro_portal_activity'; +const MAX_ITEMS = 40; + +function read(): PortalActivityItem[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function write(items: PortalActivityItem[]): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items.slice(0, MAX_ITEMS))); +} + +export const portalActivity = { + list(limit = 4): PortalActivityItem[] { + return read().filter(item => !item.text.startsWith('Viewed ')).slice(0, limit); + }, + + record(text: string, dot: PortalActivityItem['dot'] = 'violet'): PortalActivityItem[] { + const trimmed = text.trim(); + if (!trimmed) return read(); + + const timestamp = Date.now(); + const existing = read(); + const recentDuplicate = existing[0]?.text === trimmed && timestamp - existing[0].timestamp < 5000; + if (recentDuplicate) return existing; + + const next = [ + { + id: `${timestamp}-${Math.random().toString(36).slice(2, 8)}`, + text: trimmed, + timestamp, + dot, + }, + ...existing, + ]; + write(next); + window.dispatchEvent(new CustomEvent('tavro:portal-activity-changed')); + return next; + }, + + formatTime(timestamp: number): string { + const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (seconds < 60) return 'Just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days === 1) return 'Yesterday'; + if (days < 7) return `${days} days ago`; + const weeks = Math.floor(days / 7); + return `${weeks}w ago`; + }, +}; diff --git a/tavro_app/src/services/sparkApi.ts b/tavro_app/src/services/sparkApi.ts index 5c3ce8b..683dc2d 100644 --- a/tavro_app/src/services/sparkApi.ts +++ b/tavro_app/src/services/sparkApi.ts @@ -1,5 +1,6 @@ import type { SparkIdea, SparkConvertRequest } from '../types/spark'; import { appLogger } from './logger'; +import { portalActivity } from './portalActivity'; const BASE = import.meta.env.VITE_TWIN_API_URL ?? ''; const V1 = `${BASE}/api/v1`; @@ -138,6 +139,7 @@ class SparkApi { direction: direction ?? '(none)', titles: result.slice(0, 3).map(i => i.title), }, Date.now() - t0); + portalActivity.record(`Generated ${result.length} Spark idea${result.length === 1 ? '' : 's'}`, 'emerald'); return result; } catch (err) { appLogger.error('Spark generateIdeas failed', { error: (err as Error).message, direction }); @@ -152,6 +154,7 @@ class SparkApi { const t0 = Date.now(); await req(`/spark/ideas?${params.toString()}`, { method: 'DELETE' }); appLogger.res('Spark deleteIdeas', { deleted: ideaIds.length }, Date.now() - t0); + portalActivity.record(`Deleted ${ideaIds.length} Spark idea${ideaIds.length === 1 ? '' : 's'}`, 'amber'); } /** Delete all stored ideas for a company. */ @@ -160,6 +163,7 @@ class SparkApi { appLogger.req('Spark resetIdeas', { companyId }); await req(`/spark/ideas?${params.toString()}`, { method: 'DELETE' }); appLogger.res('Spark resetIdeas', {}); + portalActivity.record('Reset Spark ideas', 'amber'); } /** Expand a Spark idea into full AI use case fields + agent recommendation via Claude. */ diff --git a/tavro_app/src/services/useCaseApi.ts b/tavro_app/src/services/useCaseApi.ts index af490c7..04d5c66 100644 --- a/tavro_app/src/services/useCaseApi.ts +++ b/tavro_app/src/services/useCaseApi.ts @@ -1,4 +1,5 @@ import { getValidToken } from './auth'; +import { portalActivity } from './portalActivity'; const BASE = (import.meta as any).env?.VITE_TWIN_API_URL ?? ''; const V1 = `${BASE}/api/v1`; @@ -54,6 +55,7 @@ export interface UseCaseCreatePayload { } export interface UseCaseUpdatePayload { + __activityName?: string; title?: string; description?: string; business_problem_statement?: string; @@ -63,6 +65,18 @@ export interface UseCaseUpdatePayload { use_case_owner?: string; } +function changedUseCaseFields(payload: UseCaseUpdatePayload): string { + const fields: string[] = []; + if (payload.title !== undefined) fields.push('title'); + if (payload.description !== undefined) fields.push('description'); + if (payload.business_problem_statement !== undefined) fields.push('problem statement'); + if (payload.expected_benefits !== undefined) fields.push('expected benefits'); + if (payload.priority !== undefined) fields.push('priority'); + if (payload.solution_approach !== undefined) fields.push('solution approach'); + if (payload.use_case_owner !== undefined) fields.push('owner'); + return fields.length > 0 ? fields.join(', ') : 'details'; +} + export interface UseCaseListResponse { start_record: number; end_record: number; @@ -95,10 +109,12 @@ class UseCaseApiService { } async createUseCase(payload: UseCaseCreatePayload): Promise<{ message: string; use_case_id: string }> { - return req('/use-cases', { + const result = await req<{ message: string; use_case_id: string }>('/use-cases', { method: 'POST', body: JSON.stringify(payload), }); + portalActivity.record(`Created AI use case: ${payload.title}`, 'emerald'); + return result; } async suggestDescription(title: string): Promise<{ description: string }> { @@ -109,16 +125,21 @@ class UseCaseApiService { } async updateUseCase(useCaseId: string, payload: UseCaseUpdatePayload): Promise<{ message: string; use_case_id: string }> { - return req(`/use-cases/${encodeURIComponent(useCaseId)}`, { + const { __activityName, ...body } = payload; + const result = await req<{ message: string; use_case_id: string }>(`/use-cases/${encodeURIComponent(useCaseId)}`, { method: 'PUT', - body: JSON.stringify(payload), + body: JSON.stringify(body), }); + portalActivity.record(`Updated AI use case ${payload.title || __activityName || useCaseId}: ${changedUseCaseFields(payload)}`, 'violet'); + return result; } async deleteUseCase(useCaseId: string): Promise<{ message: string; use_case_id: string }> { - return req(`/use-cases/${encodeURIComponent(useCaseId)}`, { + const result = await req<{ message: string; use_case_id: string }>(`/use-cases/${encodeURIComponent(useCaseId)}`, { method: 'DELETE', }); + portalActivity.record(`Deleted AI use case: ${useCaseId}`, 'amber'); + return result; } async linkAgent(useCaseId: string, agentId: string): Promise<{ message: string; associated_count: number }> { From 054a9aa5519e44b11efa846f509df8f6524fa3ac Mon Sep 17 00:00:00 2001 From: prasannakumar-tavro Date: Fri, 12 Jun 2026 19:02:35 +0530 Subject: [PATCH 2/3] Home page dashboard is updated to suppport the dynamic view based on the database data --- tavro_api/api/routers/insights.py | 26 ++++- tavro_app/src/components/AddDimNodeModal.tsx | 17 ++- tavro_app/src/components/EditAgentModal.tsx | 13 ++- tavro_app/src/components/EditUseCaseModal.tsx | 38 +++++-- tavro_app/src/components/Layout.tsx | 5 +- .../src/components/LoadAIUseCaseModal.tsx | 7 ++ tavro_app/src/pages/AgentViewPage.tsx | 24 ++-- tavro_app/src/pages/AiModelViewPage.tsx | 27 ++++- .../src/pages/BusinessApplicationViewPage.tsx | 32 +++++- .../src/pages/BusinessProcessViewPage.tsx | 32 +++++- tavro_app/src/pages/IntegrationViewPage.tsx | 31 ++++- tavro_app/src/pages/UseCaseViewPage.tsx | 38 +++++-- tavro_app/src/services/agentApi.ts | 19 ++-- tavro_app/src/services/aiModelApi.ts | 62 +++++++++- .../src/services/businessRelationsApi.ts | 106 +++++++++++++++++- tavro_app/src/services/complianceApi.ts | 58 ++++++++-- tavro_app/src/services/mcpClient.ts | 1 + tavro_app/src/services/useCaseApi.ts | 8 +- 18 files changed, 472 insertions(+), 72 deletions(-) diff --git a/tavro_api/api/routers/insights.py b/tavro_api/api/routers/insights.py index 9724f54..67bc32d 100644 --- a/tavro_api/api/routers/insights.py +++ b/tavro_api/api/routers/insights.py @@ -446,6 +446,14 @@ def _pct(count: int, total: int) -> int: ("ESG & Sustainability", ["organisation", "technology"]), ] +def _profile_dimension_hint(categories: List[str], category_labels: Dict[str, str]) -> str: + labels = [_display(category_labels.get(cat), cat.title()) for cat in categories] + if not labels: + return "a Blueprint dimension" + if len(labels) == 1: + return f"a {labels[0]} dimension" + return f"{', '.join(labels[:-1])}, or {labels[-1]} dimensions" + # --------------------------------------------------------------------------- # SQL @@ -551,6 +559,12 @@ def _pct(count: int, total: int) -> int: WHERE dn.company_id = :cid AND dn.valid_to IS NULL """ +_DIM_TYPE_LABELS_SQL = """ +SELECT category::text AS category, name +FROM twin.dim_type +ORDER BY system_defined DESC NULLS LAST, name +""" + # --------------------------------------------------------------------------- # Endpoint @@ -895,10 +909,11 @@ def _home_attention_items( for gap in company_profile.get("gaps", [])[:2]: area = _display(gap.get("area"), "Profile") + dimension_hint = _display(gap.get("dimensionHint"), "Blueprint dimension") items.append({ "id": f"blueprint-gap:{gap.get('id')}", "badge": "Incomplete", - "text": f"Blueprint - {area} section not yet populated", + "text": f"Blueprint - add {dimension_hint} for {area}", "action": "Complete", "route": "/blueprint", }) @@ -926,9 +941,17 @@ async def _build_company_profile(db: AsyncSession, company_id: Optional[str]) -> if not cid: return empty nodes = (await db.execute(text(_PROFILE_NODES_SQL), {"cid": cid})).mappings().all() + dim_type_rows = (await db.execute(text(_DIM_TYPE_LABELS_SQL))).mappings().all() except Exception: # noqa: BLE001 return empty + category_labels: Dict[str, str] = {} + for row in dim_type_rows: + category = _display(row.get("category"), "") + name = _display(row.get("name"), "") + if category and name and category not in category_labels: + category_labels[category] = name + by_category: Dict[str, List[Any]] = {} for n in nodes: by_category.setdefault(n["category"], []).append(n["updated_at"]) @@ -949,6 +972,7 @@ async def _build_company_profile(db: AsyncSession, company_id: Optional[str]) -> "id": f"{s['label']}-{i}", "gap": f"{s['label']} dimensions missing", "area": s["label"], + "dimensionHint": _profile_dimension_hint(_PROFILE_SECTIONS[i][1], category_labels), "severity": "high" if i < 2 else "medium", } for i, s in enumerate(sections) if s["pct"] == 0 diff --git a/tavro_app/src/components/AddDimNodeModal.tsx b/tavro_app/src/components/AddDimNodeModal.tsx index d5ffff5..83b3779 100644 --- a/tavro_app/src/components/AddDimNodeModal.tsx +++ b/tavro_app/src/components/AddDimNodeModal.tsx @@ -8,6 +8,7 @@ import { blueprintApi } from '../services/blueprintApi'; import { useBlueprint } from '../context/BlueprintContext'; import type { DimCategory, VisibilityLevel, DimType } from '../types/blueprint'; import { CATEGORY_PALETTE, CATEGORY_LABELS } from '../types/blueprint'; +import { portalActivity } from '../services/portalActivity'; interface AddDimNodeModalProps { onClose: () => void; @@ -37,6 +38,19 @@ const REL_TYPES = [ 'enables', 'part_of', 'governed_by', 'replaced_by', 'custom', ]; +const DIM_CATEGORY_OPTIONS: DimCategory[] = [ + 'profile', + 'strategy', + 'organisation', + 'finance', + 'risk', + 'application', + 'process', + 'integration', + 'technology', + 'custom', +]; + const AddDimNodeModal: React.FC = ({ onClose, onCreated, defaultCategory, }) => { @@ -119,6 +133,7 @@ const AddDimNodeModal: React.FC = ({ if (attachments.length > 0) { await Promise.all(attachments.map(f => blueprintApi.uploadAttachment(created.id, f))); } + portalActivity.record(`Added blueprint dimension: ${created.label || label.trim()}`, 'emerald'); onCreated(); onClose(); } catch (err: any) { @@ -159,7 +174,7 @@ const AddDimNodeModal: React.FC = ({ {/* Category */}
- {(['strategy', 'organisation', 'finance', 'risk', 'application', 'process', 'integration', 'custom'] as DimCategory[]).map(cat => { + {DIM_CATEGORY_OPTIONS.map(cat => { const p = CATEGORY_PALETTE[cat]; const active = category === cat; return ( diff --git a/tavro_app/src/components/EditAgentModal.tsx b/tavro_app/src/components/EditAgentModal.tsx index 0018aa9..7832b28 100644 --- a/tavro_app/src/components/EditAgentModal.tsx +++ b/tavro_app/src/components/EditAgentModal.tsx @@ -35,11 +35,14 @@ const EditAgentModal: React.FC = ({ agent, open, onClose, o setSaving(true); setError(null); try { - await agentApi.updateAgent(agentId, { - agent_name: name.trim() || undefined, - description: description.trim() || undefined, - instruction: instruction.trim() || undefined, - }); + const currentName = agent.name ?? ''; + const currentDescription = agent.description ?? ''; + const currentInstruction = agent.identification?.instruction ?? ''; + const payload: { agent_name?: string; description?: string; instruction?: string } = {}; + if (name.trim() !== currentName) payload.agent_name = name.trim() || undefined; + if (description.trim() !== currentDescription) payload.description = description.trim() || undefined; + if (instruction.trim() !== currentInstruction) payload.instruction = instruction.trim() || undefined; + await agentApi.updateAgent(agentId, payload, currentName); const updated = { name: name.trim(), description: description.trim(), diff --git a/tavro_app/src/components/EditUseCaseModal.tsx b/tavro_app/src/components/EditUseCaseModal.tsx index 49f7d82..2872bfa 100644 --- a/tavro_app/src/components/EditUseCaseModal.tsx +++ b/tavro_app/src/components/EditUseCaseModal.tsx @@ -54,15 +54,35 @@ const EditUseCaseModal: React.FC = ({ useCase, open, onCl setSaving(true); setError(null); try { - await useCaseApi.updateUseCase(useCaseId, { - title: title.trim() || undefined, - description: description.trim() || undefined, - business_problem_statement: problemStatement.trim() || undefined, - expected_benefits: expectedBenefits.trim() || undefined, - priority: priority || undefined, - solution_approach: solutionApproach.trim(), - use_case_owner: owner.trim() || undefined, - }); + const payload: any = { + __activityName: uc.name ?? uc.title ?? useCaseId, + }; + const currentTitle = String(uc.name ?? uc.title ?? '').trim(); + const currentDescription = String(uc.description ?? '').trim(); + const currentProblemStatement = String(uc.problem_statement ?? uc.business_problem_statement ?? '').trim(); + const currentExpectedBenefits = String(uc.expected_benefits ?? '').trim(); + const currentPriority = String(uc.priority ?? '3 - Moderate'); + const currentSolutionApproach = String(uc.solution_approach ?? '').trim(); + const currentOwner = String(uc.owner ?? uc.use_case_owner ?? '').trim(); + + const nextTitle = title.trim(); + const nextDescription = description.trim(); + const nextProblemStatement = problemStatement.trim(); + const nextExpectedBenefits = expectedBenefits.trim(); + const nextSolutionApproach = solutionApproach.trim(); + const nextOwner = owner.trim(); + + if (nextTitle !== currentTitle) payload.title = nextTitle || undefined; + if (nextDescription !== currentDescription) payload.description = nextDescription || undefined; + if (nextProblemStatement !== currentProblemStatement) payload.business_problem_statement = nextProblemStatement || undefined; + if (nextExpectedBenefits !== currentExpectedBenefits) payload.expected_benefits = nextExpectedBenefits || undefined; + if (priority !== currentPriority) payload.priority = priority || undefined; + if (nextSolutionApproach !== currentSolutionApproach) payload.solution_approach = nextSolutionApproach || undefined; + if (nextOwner !== currentOwner) payload.use_case_owner = nextOwner || undefined; + + if (Object.keys(payload).length > 1) { + await useCaseApi.updateUseCase(useCaseId, payload); + } const updated = { title: title.trim(), description: description.trim(), diff --git a/tavro_app/src/components/Layout.tsx b/tavro_app/src/components/Layout.tsx index 0e47680..e83e2e8 100644 --- a/tavro_app/src/components/Layout.tsx +++ b/tavro_app/src/components/Layout.tsx @@ -90,8 +90,9 @@ const Layout: React.FC = () => { }; const agentCreated = (event: Event) => { const detail = (event as CustomEvent).detail ?? {}; - const agent = detail.agent ?? detail; - const name = agent.agent_name || agent.name || agent.agent_id || 'agent'; + const agent = detail.agent ?? detail.result ?? detail; + const args = detail.args ?? {}; + const name = args.agent_name || agent.agent_name || agent.name || agent.agent_id || 'agent'; portalActivity.record(`Created agent: ${name}`, 'emerald'); }; const agentArtifactsGenerated = (event: Event) => { diff --git a/tavro_app/src/components/LoadAIUseCaseModal.tsx b/tavro_app/src/components/LoadAIUseCaseModal.tsx index fd88847..906d8e4 100644 --- a/tavro_app/src/components/LoadAIUseCaseModal.tsx +++ b/tavro_app/src/components/LoadAIUseCaseModal.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useRef, useState } from 'react'; import { Upload, X, FileJson, AlertCircle, CheckCircle2, Loader2, Trash2, FolderOpen, Link2 } from 'lucide-react'; import { useCaseApi } from '../services/useCaseApi'; import { driveApi } from '../services/driveApi'; +import { portalActivity } from '../services/portalActivity'; interface LoadAIUseCaseModalProps { onClose: () => void; @@ -99,6 +100,12 @@ const LoadAIUseCaseModal: React.FC = ({ onClose, onSucc try { const result = await driveApi.importFromDrive(url); setDriveSuccess(result.message); + if (result.use_cases_imported > 0) { + portalActivity.record( + `Loaded ${result.use_cases_imported} AI use case${result.use_cases_imported === 1 ? '' : 's'} from Google Drive`, + 'emerald', + ); + } onSuccess(); } catch (err: any) { setDriveError(err?.message ?? 'Drive import failed. Please try again.'); diff --git a/tavro_app/src/pages/AgentViewPage.tsx b/tavro_app/src/pages/AgentViewPage.tsx index a216b10..85fdb99 100644 --- a/tavro_app/src/pages/AgentViewPage.tsx +++ b/tavro_app/src/pages/AgentViewPage.tsx @@ -563,11 +563,14 @@ const AgentViewPage: React.FC = () => { setEditError(null); try { const agentId = agent.identification?.agent_id ?? agent.name; - await agentApi.updateAgent(agentId, { - agent_name: editName.trim() || undefined, - description: editDescription.trim() || undefined, - instruction: editInstruction.trim() || undefined, - }); + const currentName = agent.name ?? ''; + const currentDescription = agent.description ?? ''; + const currentInstruction = agent.identification?.instruction ?? ''; + const payload: import('../services/agentApi').AgentUpdatePayload = {}; + if (editName.trim() !== currentName) payload.agent_name = editName.trim() || undefined; + if (editDescription.trim() !== currentDescription) payload.description = editDescription.trim() || undefined; + if (editInstruction.trim() !== currentInstruction) payload.instruction = editInstruction.trim() || undefined; + await agentApi.updateAgent(agentId, payload, currentName); handleAgentSaved({ name: editName.trim(), description: editDescription.trim(), @@ -614,11 +617,12 @@ const AgentViewPage: React.FC = () => { setEditError(null); try { const agentId = agent.identification?.agent_id ?? agent.name; - await agentApi.updateAgent(agentId, { - agent_name: nextName || undefined, - description: nextDescription || undefined, - instruction: nextInstruction || undefined, - }); + const currentName = agent.name ?? ''; + const inlinePayload: import('../services/agentApi').AgentUpdatePayload = + inlineEdit.field === 'name' ? { agent_name: nextName || undefined } + : inlineEdit.field === 'description' ? { description: nextDescription || undefined } + : { instruction: nextInstruction || undefined }; + await agentApi.updateAgent(agentId, inlinePayload, currentName); handleAgentSaved({ name: nextName, description: nextDescription, diff --git a/tavro_app/src/pages/AiModelViewPage.tsx b/tavro_app/src/pages/AiModelViewPage.tsx index 04142e7..fe0de86 100644 --- a/tavro_app/src/pages/AiModelViewPage.tsx +++ b/tavro_app/src/pages/AiModelViewPage.tsx @@ -92,6 +92,18 @@ const buildPayload = (form: FormState): AiModelUpsertPayload => { return payload as AiModelUpsertPayload; }; +const changedPayload = (current: FormState, next: FormState): AiModelUpsertPayload => { + const currentPayload = buildPayload(current); + const nextPayload = buildPayload(next); + const changed: Record = {}; + FIELD_KEYS.forEach(key => { + if ((nextPayload as Record)[key] !== (currentPayload as Record)[key]) { + changed[key] = (nextPayload as Record)[key] ?? null; + } + }); + return changed as AiModelUpsertPayload; +}; + const Field: React.FC<{ label: string; children: React.ReactNode; full?: boolean }> = ({ label, children, full }) => (
@@ -315,7 +327,13 @@ const AiModelViewPage: React.FC = () => { navigate(`/ai-models/${encodeURIComponent(created.ai_model_id)}`, { replace: true }); return; } - await aiModelApi.updateModel(model!.ai_model_id, payload); + const changed = changedPayload(formFromModel(model!), form); + if (Object.keys(changed).length === 0) { + setEditing(false); + setInlineEdit(null); + return; + } + await aiModelApi.updateModel(model!.ai_model_id, changed, model!.model_name ?? undefined); const fresh = await aiModelApi.getModel(model!.ai_model_id); setModel(fresh); setForm(formFromModel(fresh)); @@ -372,7 +390,12 @@ const AiModelViewPage: React.FC = () => { setInlineSaving(inlineEdit.field); setActionError(null); try { - await aiModelApi.updateModel(model.ai_model_id, buildPayload(nextForm)); + const changed = changedPayload(formFromModel(model), nextForm); + if (Object.keys(changed).length === 0) { + setInlineEdit(null); + return; + } + await aiModelApi.updateModel(model.ai_model_id, changed, model.model_name ?? undefined); const fresh = await aiModelApi.getModel(model.ai_model_id); setModel(fresh); setForm(formFromModel(fresh)); diff --git a/tavro_app/src/pages/BusinessApplicationViewPage.tsx b/tavro_app/src/pages/BusinessApplicationViewPage.tsx index 9f42c3b..2240b89 100644 --- a/tavro_app/src/pages/BusinessApplicationViewPage.tsx +++ b/tavro_app/src/pages/BusinessApplicationViewPage.tsx @@ -219,6 +219,21 @@ const buildApplicationPayload = (form: ApplicationFormState): BusinessApplicatio latest_release_documentation_link: toNullable(form.latest_release_documentation_link), }); +const changedApplicationPayload = ( + current: ApplicationFormState, + next: ApplicationFormState, +): BusinessApplicationUpsertPayload => { + const currentPayload = buildApplicationPayload(current); + const nextPayload = buildApplicationPayload(next); + const changed: BusinessApplicationUpsertPayload = {}; + (Object.keys(nextPayload) as Array).forEach(key => { + if (nextPayload[key] !== currentPayload[key]) { + (changed as Record)[key] = nextPayload[key] ?? null; + } + }); + return changed; +}; + const labelFromOptions = (value: string, options: Option[]): string => { if (!value) return 'N/A'; const found = options.find(o => o.value === value); @@ -435,9 +450,15 @@ const BusinessApplicationViewPage: React.FC = () => { setInlineSaving(inlineEdit.field); setActionError(null); try { + const changedPayload = changedApplicationPayload(formFromApplication(application), nextForm); + if (Object.keys(changedPayload).length === 0) { + setInlineEdit(null); + setAttemptedSave(false); + return; + } const updated = await businessRelationsApi.updateApplication( application.business_application_id, - buildApplicationPayload(nextForm), + changedPayload, ); setApplication(updated); setForm(formFromApplication(updated)); @@ -584,7 +605,14 @@ const BusinessApplicationViewPage: React.FC = () => { return; } if (!application) return; - const updated = await businessRelationsApi.updateApplication(application.business_application_id, payload); + const changedPayload = changedApplicationPayload(formFromApplication(application), form); + if (Object.keys(changedPayload).length === 0) { + setAttemptedSave(false); + setInlineEdit(null); + setEditing(false); + return; + } + const updated = await businessRelationsApi.updateApplication(application.business_application_id, changedPayload); setApplication(updated); setForm(formFromApplication(updated)); setAttemptedSave(false); diff --git a/tavro_app/src/pages/BusinessProcessViewPage.tsx b/tavro_app/src/pages/BusinessProcessViewPage.tsx index f5b7e2e..03dcb34 100644 --- a/tavro_app/src/pages/BusinessProcessViewPage.tsx +++ b/tavro_app/src/pages/BusinessProcessViewPage.tsx @@ -197,6 +197,21 @@ const buildProcessPayload = (form: ProcessFormState): BusinessProcessUpsertPaylo process_health_state: toNullable(form.process_health_state), }); +const changedProcessPayload = ( + current: ProcessFormState, + next: ProcessFormState, +): BusinessProcessUpsertPayload => { + const currentPayload = buildProcessPayload(current); + const nextPayload = buildProcessPayload(next); + const changed: BusinessProcessUpsertPayload = {}; + (Object.keys(nextPayload) as Array).forEach(key => { + if (nextPayload[key] !== currentPayload[key]) { + (changed as Record)[key] = nextPayload[key] ?? null; + } + }); + return changed; +}; + const labelFromOptions = (value: string, options: Option[]): string => { if (!value) return 'N/A'; const found = options.find(o => o.value === value); @@ -467,9 +482,15 @@ const BusinessProcessViewPage: React.FC = () => { setInlineSaving(inlineEdit.field); setActionError(null); try { + const changedPayload = changedProcessPayload(formFromProcess(process), nextForm); + if (Object.keys(changedPayload).length === 0) { + setInlineEdit(null); + setAttemptedSave(false); + return; + } const updated = await businessRelationsApi.updateProcess( process.business_process_id, - buildProcessPayload(nextForm), + changedPayload, ); setProcess(updated); setForm(formFromProcess(updated)); @@ -620,7 +641,14 @@ const BusinessProcessViewPage: React.FC = () => { return; } if (!process) return; - const updated = await businessRelationsApi.updateProcess(process.business_process_id, payload); + const changedPayload = changedProcessPayload(formFromProcess(process), form); + if (Object.keys(changedPayload).length === 0) { + setAttemptedSave(false); + setInlineEdit(null); + setEditing(false); + return; + } + const updated = await businessRelationsApi.updateProcess(process.business_process_id, changedPayload); setProcess(updated); setForm(formFromProcess(updated)); setAttemptedSave(false); diff --git a/tavro_app/src/pages/IntegrationViewPage.tsx b/tavro_app/src/pages/IntegrationViewPage.tsx index dbeba2f..b8baca1 100644 --- a/tavro_app/src/pages/IntegrationViewPage.tsx +++ b/tavro_app/src/pages/IntegrationViewPage.tsx @@ -157,6 +157,21 @@ const buildIntegrationPayload = (form: IntegrationFormState): IntegrationUpsertP parent_application_id: toNullable(form.parent_application_id), }); +const changedIntegrationPayload = ( + current: IntegrationFormState, + next: IntegrationFormState, +): IntegrationUpsertPayload => { + const currentPayload = buildIntegrationPayload(current); + const nextPayload = buildIntegrationPayload(next); + const changed: IntegrationUpsertPayload = {}; + (Object.keys(nextPayload) as Array).forEach(key => { + if (nextPayload[key] !== currentPayload[key]) { + (changed as Record)[key] = nextPayload[key] ?? null; + } + }); + return changed; +}; + const HintLabel: React.FC<{ label: string; hint?: string; required?: boolean }> = ({ label, hint, required }) => (
+
); };