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 00000000..7e83f476 --- /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 7e83f476..a4781e1f 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 67cf33f0..1a222f27 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 7a1290e0..2e5a2006 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 c01072d9..07862c58 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 d1dd1ddc..f6820f52 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 c4485d30..b1b53759 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 a3e35de1..ed6c9c51 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 b579a65d..9b8a7145 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 4737d047..fa981d1b 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 00000000..ac933aa1 --- /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 e12e6717..12cc6f88 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" /> +