From c8161669aca6ea98f26954b8dc7b724ab0d0b20f Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 15 Dec 2025 00:42:16 +0000 Subject: [PATCH] Add shared instance metadata context --- packages/ui/src/App.tsx | 34 ++--- packages/ui/src/components/instance-info.tsx | 49 +++---- .../components/instance-service-status.tsx | 57 ++++---- .../components/instance/instance-shell2.tsx | 3 - .../contexts/instance-metadata-context.tsx | 72 ++++++++++ .../ui/src/lib/hooks/use-instance-metadata.ts | 124 +++++++----------- 6 files changed, 193 insertions(+), 146 deletions(-) create mode 100644 packages/ui/src/lib/contexts/instance-metadata-context.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 1093393a..cc1b8762 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -8,6 +8,7 @@ import InstanceTabs from "./components/instance-tabs" import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceShell from "./components/instance/instance-shell2" import { RemoteAccessOverlay } from "./components/remote-access-overlay" +import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { initMarkdown } from "./lib/markdown" import { useTheme } from "./lib/theme" @@ -351,22 +352,25 @@ const App: Component = () => { {(instance) => { const isActiveInstance = () => activeInstanceId() === instance.id const isVisible = () => isActiveInstance() && !showFolderSelection() - return ( -
- handleCloseSession(instance.id, sessionId)} - onNewSession={() => handleNewSession(instance.id)} - handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} - handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} - onExecuteCommand={executeCommand} - tabBarOffset={instanceTabBarHeight()} - /> + return ( +
+ + handleCloseSession(instance.id, sessionId)} + onNewSession={() => handleNewSession(instance.id)} + handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} + handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} + onExecuteCommand={executeCommand} + tabBarOffset={instanceTabBarHeight()} + /> + + +
+ ) -
- ) }} diff --git a/packages/ui/src/components/instance-info.tsx b/packages/ui/src/components/instance-info.tsx index 9ea82f4c..15db4ac7 100644 --- a/packages/ui/src/components/instance-info.tsx +++ b/packages/ui/src/components/instance-info.tsx @@ -1,6 +1,6 @@ -import { Component, For, Show } from "solid-js" +import { Component, For, Show, createMemo } from "solid-js" import type { Instance } from "../types/instance" -import { useInstanceMetadata } from "../lib/hooks/use-instance-metadata" +import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" import InstanceServiceStatus from "./instance-service-status" interface InstanceInfoProps { @@ -9,10 +9,19 @@ interface InstanceInfoProps { } const InstanceInfo: Component = (props) => { - const { isLoading: isLoadingMetadata } = useInstanceMetadata(() => props.instance) + const metadataContext = useOptionalInstanceMetadataContext() + const isLoadingMetadata = metadataContext?.isLoading ?? (() => false) + const instanceAccessor = metadataContext?.instance ?? (() => props.instance) + const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata) - const metadata = () => props.instance.metadata - const binaryVersion = () => props.instance.binaryVersion || metadata()?.version + const currentInstance = () => instanceAccessor() + const metadata = () => metadataAccessor() + const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version + const environmentVariables = () => currentInstance().environmentVariables + const environmentEntries = createMemo(() => { + const env = environmentVariables() + return env ? Object.entries(env) : [] + }) return (
@@ -23,7 +32,7 @@ const InstanceInfo: Component = (props) => {
Folder
- {props.instance.folder} + {currentInstance().folder}
@@ -72,24 +81,24 @@ const InstanceInfo: Component = (props) => {
- +
Binary Path
- {props.instance.binaryPath} + {currentInstance().binaryPath}
- 0}> + 0}>
- Environment Variables ({Object.keys(props.instance.environmentVariables!).length}) + Environment Variables ({environmentEntries().length})
- + {([key, value]) => (
@@ -105,11 +114,7 @@ const InstanceInfo: Component = (props) => {
- +
@@ -132,21 +137,19 @@ const InstanceInfo: Component = (props) => {
Port: - {props.instance.port} + {currentInstance().port}
PID: - {props.instance.pid} + {currentInstance().pid}
Status: - +
- {props.instance.status} + {currentInstance().status}
diff --git a/packages/ui/src/components/instance-service-status.tsx b/packages/ui/src/components/instance-service-status.tsx index cf7a198e..c4971603 100644 --- a/packages/ui/src/components/instance-service-status.tsx +++ b/packages/ui/src/components/instance-service-status.tsx @@ -1,8 +1,7 @@ import { For, Show, createMemo, createSignal, type Component } from "solid-js" import Switch from "@suid/material/Switch" import type { Instance, RawMcpStatus } from "../types/instance" -import { instances, updateInstance } from "../stores/instances" -import { useInstanceMetadata } from "../lib/hooks/use-instance-metadata" +import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" import { getLogger } from "../lib/logger" const log = getLogger("session") @@ -10,11 +9,10 @@ const log = getLogger("session") type ServiceSection = "lsp" | "mcp" interface InstanceServiceStatusProps { - instanceId: string - initialInstance?: Instance sections?: ServiceSection[] showSectionHeadings?: boolean class?: string + initialInstance?: Instance } type ParsedMcpStatus = { @@ -44,17 +42,30 @@ function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] { } const InstanceServiceStatus: Component = (props) => { - const instance = createMemo(() => instances().get(props.instanceId) ?? props.initialInstance) + const metadataContext = useOptionalInstanceMetadataContext() + const instance = metadataContext?.instance ?? (() => { + if (props.initialInstance) { + return props.initialInstance + } + throw new Error("InstanceServiceStatus requires InstanceMetadataProvider or initialInstance prop") + }) + const isLoading = metadataContext?.isLoading ?? (() => false) + const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve()) const sections = createMemo(() => props.sections ?? ["lsp", "mcp"]) const includeLsp = createMemo(() => sections().includes("lsp")) const includeMcp = createMemo(() => sections().includes("mcp")) const showHeadings = () => props.showSectionHeadings !== false - const { isLoading } = useInstanceMetadata(instance) - const metadata = createMemo(() => instance()?.metadata) + const metadata = createMemo(() => instance().metadata) + const hasLspMetadata = () => metadata()?.lspStatus !== undefined + const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined const lspServers = createMemo(() => metadata()?.lspStatus ?? []) const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus)) + const isLspLoading = () => isLoading() || !hasLspMetadata() + const isMcpLoading = () => isLoading() || !hasMcpMetadata() + + const [pendingMcpActions, setPendingMcpActions] = createSignal>({}) const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => { @@ -66,26 +77,8 @@ const InstanceServiceStatus: Component = (props) => }) } - const refreshMcpStatus = async () => { - const client = instance()?.client - if (!client?.mcp?.status) return - try { - const result = await client.mcp.status() - const status = result.data as RawMcpStatus | undefined - if (!status) return - updateInstance(props.instanceId, { - metadata: { - ...(instance()?.metadata ?? {}), - mcpStatus: status, - }, - }) - } catch (error) { - log.error("Failed to refresh MCP status", error) - } - } - const toggleMcpServer = async (serverName: string, shouldEnable: boolean) => { - const client = instance()?.client + const client = instance().client if (!client?.mcp) return const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect" setPendingMcpAction(serverName, action) @@ -95,7 +88,7 @@ const InstanceServiceStatus: Component = (props) => } else { await client.mcp.disconnect({ path: { name: serverName } }) } - await refreshMcpStatus() + await refreshMetadata() } catch (error) { log.error("Failed to toggle MCP server", { serverName, action, error }) } finally { @@ -117,8 +110,8 @@ const InstanceServiceStatus: Component = (props) =>
0} - fallback={renderEmptyState(isLoading() ? "Loading server status..." : "No LSP servers detected.")} + when={!isLspLoading() && lspServers().length > 0} + fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")} >
@@ -152,8 +145,8 @@ const InstanceServiceStatus: Component = (props) =>
0} - fallback={renderEmptyState(isLoading() ? "Loading server status..." : "No MCP servers detected.")} + when={!isMcpLoading() && mcpServers().length > 0} + fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")} >
@@ -161,7 +154,7 @@ const InstanceServiceStatus: Component = (props) => const pendingAction = () => pendingMcpActions()[server.name] const isPending = () => Boolean(pendingAction()) const isRunning = () => server.status === "running" - const switchDisabled = () => isPending() || !instance()?.client + const switchDisabled = () => isPending() || !instance().client const statusDotClass = () => { if (isPending()) return "status-dot animate-pulse" if (server.status === "running") return "status-dot ready animate-pulse" diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 7580ac9e..a1a239bf 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -132,7 +132,6 @@ const InstanceShell2: Component = (props) => { const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) - const desktopQuery = useMediaQuery("(min-width: 1280px)") const tabletQuery = useMediaQuery("(min-width: 768px)") @@ -860,7 +859,6 @@ const InstanceShell2: Component = (props) => { label: "LSP Servers", render: () => ( = (props) => { label: "MCP Servers", render: () => ( + instance: Accessor + metadata: Accessor + refreshMetadata: () => Promise +} + +const InstanceMetadataContext = createContext(null) + +interface InstanceMetadataProviderProps { + instance: Instance + children: JSX.Element +} + +export const InstanceMetadataProvider: Component = (props) => { + const resolvedInstance = createMemo(() => instances().get(props.instance.id) ?? props.instance) + const [isLoading, setIsLoading] = createSignal(true) + + const ensureMetadata = async (force = false) => { + const current = resolvedInstance() + if (!current) { + setIsLoading(false) + return + } + + if (!force && hasMetadataLoaded(current.metadata)) { + setIsLoading(false) + return + } + + setIsLoading(true) + await loadInstanceMetadata(current, { force }) + setIsLoading(false) + } + + createEffect(() => { + const current = resolvedInstance() + // Ensure metadata becomes a dependency so we re-check when store updates + void current?.metadata + void ensureMetadata() + }) + + const contextValue: InstanceMetadataContextValue = { + isLoading, + instance: resolvedInstance, + metadata: () => resolvedInstance().metadata, + refreshMetadata: () => ensureMetadata(true), + } + + return ( + + {props.children} + + ) +} + +export function useInstanceMetadataContext(): InstanceMetadataContextValue { + const ctx = useContext(InstanceMetadataContext) + if (!ctx) { + throw new Error("useInstanceMetadataContext must be used within InstanceMetadataProvider") + } + return ctx +} + +export function useOptionalInstanceMetadataContext(): InstanceMetadataContextValue | null { + return useContext(InstanceMetadataContext) +} diff --git a/packages/ui/src/lib/hooks/use-instance-metadata.ts b/packages/ui/src/lib/hooks/use-instance-metadata.ts index cd538e29..929f7d86 100644 --- a/packages/ui/src/lib/hooks/use-instance-metadata.ts +++ b/packages/ui/src/lib/hooks/use-instance-metadata.ts @@ -1,4 +1,3 @@ -import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" import type { Instance, RawMcpStatus } from "../../types/instance" import { fetchLspStatus, updateInstance } from "../../stores/instances" import { getLogger } from "../../lib/logger" @@ -6,87 +5,66 @@ import { getLogger } from "../../lib/logger" const log = getLogger("session") const pendingMetadataRequests = new Set() -export function useInstanceMetadata(instanceAccessor: Accessor) { - const [isLoading, setIsLoading] = createSignal(true) +function hasMetadataLoaded(metadata?: Instance["metadata"]): boolean { + if (!metadata) return false + return "project" in metadata && "mcpStatus" in metadata && "lspStatus" in metadata +} - createEffect(() => { - const instance = instanceAccessor() - if (!instance) { - setIsLoading(false) - return +export async function loadInstanceMetadata(instance: Instance, options?: { force?: boolean }): Promise { + const client = instance.client + if (!client) { + log.warn("[metadata] Skipping fetch; client missing", { instanceId: instance.id }) + return + } + + if (!options?.force && hasMetadataLoaded(instance.metadata)) { + return + } + + if (pendingMetadataRequests.has(instance.id)) { + return + } + + pendingMetadataRequests.add(instance.id) + + try { + const [projectResult, mcpResult, lspResult] = await Promise.allSettled([ + client.project.current(), + client.mcp.status(), + fetchLspStatus(instance.id), + ]) + + const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined + const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined + const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined + + const nextMetadata: Instance["metadata"] = { + ...(instance.metadata ?? {}), } - const instanceId = instance.id - const client = instance.client - const hasMetadata = Boolean(instance.metadata) - - if (!client) { - setIsLoading(false) - pendingMetadataRequests.delete(instanceId) - return + if (projectResult.status === "fulfilled") { + nextMetadata.project = project ?? undefined } - if (hasMetadata) { - setIsLoading(false) - pendingMetadataRequests.delete(instanceId) - return + if (mcpResult.status === "fulfilled") { + nextMetadata.mcpStatus = mcpStatus ?? nextMetadata.mcpStatus ?? {} } - if (pendingMetadataRequests.has(instanceId)) { - setIsLoading(true) - return + if (lspResult.status === "fulfilled") { + nextMetadata.lspStatus = lspStatus ?? [] } - let cancelled = false - pendingMetadataRequests.add(instanceId) - setIsLoading(true) + if (!nextMetadata?.version && instance.binaryVersion) { + nextMetadata.version = instance.binaryVersion + } - void (async () => { - try { - const [projectResult, mcpResult, lspResult] = await Promise.allSettled([ - client.project.current(), - client.mcp.status(), - fetchLspStatus(instanceId), - ]) - - if (cancelled) { - return - } - - const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined - const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined - const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined - - const nextMetadata: Instance["metadata"] = { - ...(instance.metadata ?? {}), - ...(project ? { project } : {}), - ...(mcpStatus ? { mcpStatus } : {}), - ...(lspStatus ? { lspStatus } : {}), - } - - if (!nextMetadata?.version && instance.binaryVersion) { - nextMetadata.version = instance.binaryVersion - } - - updateInstance(instanceId, { metadata: nextMetadata }) - } catch (error) { - if (!cancelled) { - log.error("Failed to load instance metadata", error) - } - } finally { - pendingMetadataRequests.delete(instanceId) - if (!cancelled) { - setIsLoading(false) - } - } - })() - - onCleanup(() => { - cancelled = true - }) - }) - - return { - isLoading, + updateInstance(instance.id, { metadata: nextMetadata }) + } catch (error) { + log.error("Failed to load instance metadata", error) + } finally { + pendingMetadataRequests.delete(instance.id) } } + +export { hasMetadataLoaded } +