From 905403c1afd3a2031e640f955fc1d689ca9be974 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 07:52:37 -0500 Subject: [PATCH 1/4] Compact grouped heartbeat list on instance settings page Group agents by company with a single card per company and dense inline rows instead of one card per agent. Replaces the three stat cards with a slim inline summary. Each row shows status badge, linked agent name, role, interval, last heartbeat time, a config link icon, and an enable/disable button. Co-Authored-By: Paperclip --- ui/src/pages/InstanceSettings.tsx | 213 ++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 ui/src/pages/InstanceSettings.tsx diff --git a/ui/src/pages/InstanceSettings.tsx b/ui/src/pages/InstanceSettings.tsx new file mode 100644 index 0000000..7e83f47 --- /dev/null +++ b/ui/src/pages/InstanceSettings.tsx @@ -0,0 +1,213 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Clock3, ExternalLink, Settings } from "lucide-react"; +import type { InstanceSchedulerHeartbeatAgent } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { heartbeatsApi } from "../api/heartbeats"; +import { agentsApi } from "../api/agents"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { EmptyState } from "../components/EmptyState"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { queryKeys } from "../lib/queryKeys"; +import { formatDateTime, relativeTime } from "../lib/utils"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function humanize(value: string) { + return value.replaceAll("_", " "); +} + +function buildAgentHref(agent: InstanceSchedulerHeartbeatAgent) { + return `/${agent.companyIssuePrefix}/agents/${encodeURIComponent(agent.agentUrlKey)}`; +} + +export function InstanceSettings() { + const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); + const [actionError, setActionError] = useState(null); + + useEffect(() => { + setBreadcrumbs([ + { label: "Instance Settings" }, + { label: "Heartbeats" }, + ]); + }, [setBreadcrumbs]); + + const heartbeatsQuery = useQuery({ + queryKey: queryKeys.instance.schedulerHeartbeats, + queryFn: () => heartbeatsApi.listInstanceSchedulerAgents(), + refetchInterval: 15_000, + }); + + const toggleMutation = useMutation({ + mutationFn: async (agentRow: InstanceSchedulerHeartbeatAgent) => { + const agent = await agentsApi.get(agentRow.id, agentRow.companyId); + const runtimeConfig = asRecord(agent.runtimeConfig) ?? {}; + const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {}; + + return agentsApi.update( + agentRow.id, + { + runtimeConfig: { + ...runtimeConfig, + heartbeat: { + ...heartbeat, + enabled: !agentRow.heartbeatEnabled, + }, + }, + }, + agentRow.companyId, + ); + }, + onSuccess: async (_, agentRow) => { + setActionError(null); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }), + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agentRow.companyId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentRow.id) }), + ]); + }, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to update heartbeat."); + }, + }); + + if (heartbeatsQuery.isLoading) { + return
Loading scheduler heartbeats...
; + } + + if (heartbeatsQuery.error) { + return ( +
+ {heartbeatsQuery.error instanceof Error + ? heartbeatsQuery.error.message + : "Failed to load scheduler heartbeats."} +
+ ); + } + + const agents = heartbeatsQuery.data ?? []; + const activeCount = agents.filter((agent) => agent.schedulerActive).length; + const disabledCount = agents.length - activeCount; + + const grouped = useMemo(() => { + const map = new Map(); + for (const agent of agents) { + let group = map.get(agent.companyId); + if (!group) { + group = { companyName: agent.companyName, agents: [] }; + map.set(agent.companyId, group); + } + group.agents.push(agent); + } + return [...map.values()]; + }, [agents]); + + return ( +
+
+
+ +

Scheduler Heartbeats

+
+

+ Shows timer-based heartbeats where intervalSec > 0 and agent status is not + paused, terminated, or pending approval. Toggling a row only changes{" "} + runtimeConfig.heartbeat.enabled. +

+
+ +
+ {activeCount} active + {disabledCount} disabled + {grouped.length} {grouped.length === 1 ? "company" : "companies"} +
+ + {actionError && ( +
+ {actionError} +
+ )} + + {agents.length === 0 ? ( + + ) : ( +
+ {grouped.map((group) => ( + + +
+ {group.companyName} +
+
+ {group.agents.map((agent) => { + const saving = toggleMutation.isPending && toggleMutation.variables?.id === agent.id; + return ( +
+ + {agent.schedulerActive ? "On" : "Off"} + + + {agent.agentName} + + + {humanize(agent.title ?? agent.role)} + + + {agent.intervalSec}s + + + {agent.lastHeartbeatAt + ? relativeTime(agent.lastHeartbeatAt) + : "never"} + + + + + + + +
+ ); + })} +
+
+
+ ))} +
+ )} +
+ ); +} From 369dfa4397f6a57d115cf71a38906aebc0a81cfb Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 07:58:54 -0500 Subject: [PATCH 2/4] Fix hooks order violation and UX copy on instance settings page Move useMemo and derived state above early returns so hooks are always called in the same order. Simplify the description to plain English and change toggle button labels to "Enable Timer Heartbeat" / "Disable Timer Heartbeat" for clarity. Co-Authored-By: Paperclip --- ui/src/pages/InstanceSettings.tsx | 34 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/ui/src/pages/InstanceSettings.tsx b/ui/src/pages/InstanceSettings.tsx index 7e83f47..a4781e1 100644 --- a/ui/src/pages/InstanceSettings.tsx +++ b/ui/src/pages/InstanceSettings.tsx @@ -77,20 +77,6 @@ export function InstanceSettings() { }, }); - if (heartbeatsQuery.isLoading) { - return
Loading scheduler heartbeats...
; - } - - if (heartbeatsQuery.error) { - return ( -
- {heartbeatsQuery.error instanceof Error - ? heartbeatsQuery.error.message - : "Failed to load scheduler heartbeats."} -
- ); - } - const agents = heartbeatsQuery.data ?? []; const activeCount = agents.filter((agent) => agent.schedulerActive).length; const disabledCount = agents.length - activeCount; @@ -108,6 +94,20 @@ export function InstanceSettings() { return [...map.values()]; }, [agents]); + if (heartbeatsQuery.isLoading) { + return
Loading scheduler heartbeats...
; + } + + if (heartbeatsQuery.error) { + return ( +
+ {heartbeatsQuery.error instanceof Error + ? heartbeatsQuery.error.message + : "Failed to load scheduler heartbeats."} +
+ ); + } + return (
@@ -116,9 +116,7 @@ export function InstanceSettings() {

Scheduler Heartbeats

- Shows timer-based heartbeats where intervalSec > 0 and agent status is not - paused, terminated, or pending approval. Toggling a row only changes{" "} - runtimeConfig.heartbeat.enabled. + Agents with a timer heartbeat enabled across all of your companies.

@@ -196,7 +194,7 @@ export function InstanceSettings() { disabled={saving} onClick={() => toggleMutation.mutate(agent)} > - {saving ? "..." : agent.heartbeatEnabled ? "Disable" : "Enable"} + {saving ? "..." : agent.heartbeatEnabled ? "Disable Timer Heartbeat" : "Enable Timer Heartbeat"} From 32bdcf1dca5cbf649928800feb58815fbafa927d Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 08:03:55 -0500 Subject: [PATCH 3/4] Add instance heartbeat settings sidebar --- packages/shared/src/index.ts | 1 + packages/shared/src/types/heartbeat.ts | 19 ++++++ packages/shared/src/types/index.ts | 1 + pnpm-lock.yaml | 49 ++++++++++++++ server/src/routes/agents.ts | 92 ++++++++++++++++++++++++++ ui/src/App.tsx | 14 ++++ ui/src/api/heartbeats.ts | 8 ++- ui/src/components/CompanyRail.tsx | 14 +++- ui/src/components/InstanceSidebar.tsx | 21 ++++++ ui/src/components/Layout.tsx | 34 ++++++++-- ui/src/lib/company-routes.ts | 2 +- ui/src/lib/queryKeys.ts | 3 + 12 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 ui/src/components/InstanceSidebar.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 67cf33f..1a222f2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -99,6 +99,7 @@ export type { AgentRuntimeState, AgentTaskSession, AgentWakeupRequest, + InstanceSchedulerHeartbeatAgent, LiveEvent, DashboardSummary, ActivityEvent, diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index 7a1290e..2e5a200 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -1,4 +1,6 @@ import type { + AgentRole, + AgentStatus, HeartbeatInvocationSource, HeartbeatRunStatus, WakeupTriggerDetail, @@ -105,3 +107,20 @@ export interface AgentWakeupRequest { createdAt: Date; updatedAt: Date; } + +export interface InstanceSchedulerHeartbeatAgent { + id: string; + companyId: string; + companyName: string; + companyIssuePrefix: string; + agentName: string; + agentUrlKey: string; + role: AgentRole; + title: string | null; + status: AgentStatus; + adapterType: string; + intervalSec: number; + heartbeatEnabled: boolean; + schedulerActive: boolean; + lastHeartbeatAt: Date | null; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c01072d..07862c5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -48,6 +48,7 @@ export type { AgentRuntimeState, AgentTaskSession, AgentWakeupRequest, + InstanceSchedulerHeartbeatAgent, } from "./heartbeat.js"; export type { LiveEvent } from "./live.js"; export type { DashboardSummary } from "./dashboard.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1dd1dd..f6820f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@playwright/test': specifier: ^1.58.2 version: 1.58.2 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -38,6 +41,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-gemini-local': + specifier: workspace:* + version: link:../packages/adapters/gemini-local '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -68,6 +74,9 @@ importers: drizzle-orm: specifier: 0.38.4 version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) + embedded-postgres: + specifier: ^18.1.0-beta.16 + version: 18.1.0-beta.16 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -139,6 +148,22 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/adapters/gemini-local: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/adapters/openclaw-gateway: dependencies: '@paperclipai/adapter-utils': @@ -245,6 +270,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-gemini-local': + specifier: workspace:* + version: link:../packages/adapters/gemini-local '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -321,6 +349,9 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -360,6 +391,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-gemini-local': + specifier: workspace:* + version: link:../packages/adapters/gemini-local '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -989,6 +1023,9 @@ packages: cpu: [x64] os: [win32] + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -3424,6 +3461,11 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6741,6 +6783,8 @@ snapshots: '@embedded-postgres/windows-x64@18.1.0-beta.16': optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -9255,6 +9299,11 @@ snapshots: crelt@1.0.6: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c4485d3..b1b5375 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -8,9 +8,11 @@ import { createAgentKeySchema, createAgentHireSchema, createAgentSchema, + deriveAgentUrlKey, isUuidLike, resetAgentSessionSchema, testAdapterEnvironmentSchema, + type InstanceSchedulerHeartbeatAgent, updateAgentPermissionsSchema, updateAgentInstructionsPathSchema, wakeAgentSchema, @@ -202,6 +204,21 @@ export function agentRoutes(db: Db) { return null; } + function parseNumberLike(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return null; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + + function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) { + const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {}; + return { + enabled: parseBooleanLike(heartbeat.enabled) ?? true, + intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0), + }; + } + function generateEd25519PrivateKeyPem(): string { const { privateKey } = generateKeyPairSync("ed25519"); return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); @@ -454,6 +471,81 @@ export function agentRoutes(db: Db) { res.json(result.map((agent) => redactForRestrictedAgentView(agent))); }); + router.get("/instance/scheduler-heartbeats", async (req, res) => { + assertBoard(req); + + const accessConditions = []; + if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) { + const allowedCompanyIds = req.actor.companyIds ?? []; + if (allowedCompanyIds.length === 0) { + res.json([]); + return; + } + accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds)); + } + + const rows = await db + .select({ + id: agentsTable.id, + companyId: agentsTable.companyId, + agentName: agentsTable.name, + role: agentsTable.role, + title: agentsTable.title, + status: agentsTable.status, + adapterType: agentsTable.adapterType, + runtimeConfig: agentsTable.runtimeConfig, + lastHeartbeatAt: agentsTable.lastHeartbeatAt, + companyName: companies.name, + companyIssuePrefix: companies.issuePrefix, + }) + .from(agentsTable) + .innerJoin(companies, eq(agentsTable.companyId, companies.id)) + .where(accessConditions.length > 0 ? and(...accessConditions) : undefined) + .orderBy(companies.name, agentsTable.name); + + const items: InstanceSchedulerHeartbeatAgent[] = rows + .map((row) => { + const policy = parseSchedulerHeartbeatPolicy(row.runtimeConfig); + const statusEligible = + row.status !== "paused" && + row.status !== "terminated" && + row.status !== "pending_approval"; + + return { + id: row.id, + companyId: row.companyId, + companyName: row.companyName, + companyIssuePrefix: row.companyIssuePrefix, + agentName: row.agentName, + agentUrlKey: deriveAgentUrlKey(row.agentName, row.id), + role: row.role as InstanceSchedulerHeartbeatAgent["role"], + title: row.title, + status: row.status as InstanceSchedulerHeartbeatAgent["status"], + adapterType: row.adapterType, + intervalSec: policy.intervalSec, + heartbeatEnabled: policy.enabled, + schedulerActive: statusEligible && policy.enabled && policy.intervalSec > 0, + lastHeartbeatAt: row.lastHeartbeatAt, + }; + }) + .filter((item) => + item.intervalSec > 0 && + item.status !== "paused" && + item.status !== "terminated" && + item.status !== "pending_approval", + ) + .sort((left, right) => { + if (left.schedulerActive !== right.schedulerActive) { + return left.schedulerActive ? -1 : 1; + } + const companyOrder = left.companyName.localeCompare(right.companyName); + if (companyOrder !== 0) return companyOrder; + return left.agentName.localeCompare(right.agentName); + }); + + res.json(items); + }); + router.get("/companies/:companyId/org", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a3e35de..ed6c9c5 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,6 +23,7 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; +import { InstanceSettings } from "./pages/InstanceSettings"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; @@ -109,6 +110,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -156,6 +159,11 @@ function InboxRootRedirect() { return ; } +function LegacySettingsRedirect() { + const location = useLocation(); + return ; +} + function CompanyRootRedirect() { const { companies, selectedCompany, loading } = useCompany(); const { onboardingOpen } = useDialog(); @@ -234,9 +242,15 @@ export function App() { }> } /> + } /> + }> + } /> + } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index b579a65..9b8a714 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -1,4 +1,8 @@ -import type { HeartbeatRun, HeartbeatRunEvent } from "@paperclipai/shared"; +import type { + HeartbeatRun, + HeartbeatRunEvent, + InstanceSchedulerHeartbeatAgent, +} from "@paperclipai/shared"; import { api } from "./client"; export interface ActiveRunForIssue extends HeartbeatRun { @@ -45,4 +49,6 @@ export const heartbeatsApi = { api.get(`/issues/${issueId}/active-run`), liveRunsForCompany: (companyId: string, minCount?: number) => api.get(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`), + listInstanceSchedulerAgents: () => + api.get("/instance/scheduler-heartbeats"), }; diff --git a/ui/src/components/CompanyRail.tsx b/ui/src/components/CompanyRail.tsx index 4737d04..fa981d1 100644 --- a/ui/src/components/CompanyRail.tsx +++ b/ui/src/components/CompanyRail.tsx @@ -22,6 +22,7 @@ import { cn } from "../lib/utils"; import { queryKeys } from "../lib/queryKeys"; import { sidebarBadgesApi } from "../api/sidebarBadges"; import { heartbeatsApi } from "../api/heartbeats"; +import { useLocation, useNavigate } from "@/lib/router"; import { Tooltip, TooltipContent, @@ -154,6 +155,10 @@ function SortableCompanyItem({ export function CompanyRail() { const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { openOnboarding } = useDialog(); + const navigate = useNavigate(); + const location = useLocation(); + const isInstanceRoute = location.pathname.startsWith("/instance/"); + const highlightedCompanyId = isInstanceRoute ? null : selectedCompanyId; const sidebarCompanies = useMemo( () => companies.filter((company) => company.status !== "archived"), [companies], @@ -282,10 +287,15 @@ export function CompanyRail() { setSelectedCompanyId(company.id)} + onSelect={() => { + setSelectedCompanyId(company.id); + if (isInstanceRoute) { + navigate(`/${company.issuePrefix}/dashboard`); + } + }} /> ))} diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx new file mode 100644 index 0000000..ac933aa --- /dev/null +++ b/ui/src/components/InstanceSidebar.tsx @@ -0,0 +1,21 @@ +import { Clock3, Settings } from "lucide-react"; +import { SidebarNavItem } from "./SidebarNavItem"; + +export function InstanceSidebar() { + return ( + + ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index e12e671..12cc6f8 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { BookOpen, Moon, Sun } from "lucide-react"; -import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; +import { BookOpen, Moon, Settings, Sun } from "lucide-react"; +import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; +import { InstanceSidebar } from "./InstanceSidebar"; import { SidebarNavItem } from "./SidebarNavItem"; import { BreadcrumbBar } from "./BreadcrumbBar"; import { PropertiesPanel } from "./PropertiesPanel"; @@ -42,6 +43,7 @@ export function Layout() { const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); + const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); @@ -242,7 +244,7 @@ export function Layout() { >
- + {isInstanceSettingsRoute ? : }
@@ -252,6 +254,18 @@ export function Layout() { icon={BookOpen} className="flex-1 min-w-0" /> +
@@ -287,6 +301,18 @@ export function Layout() { icon={BookOpen} className="flex-1 min-w-0" /> +