Add shared instance metadata context

This commit is contained in:
Shantur Rathore
2025-12-15 00:42:16 +00:00
parent 8ec57da275
commit c8161669ac
6 changed files with 193 additions and 146 deletions

View File

@@ -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 (
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
<InstanceShell
instance={instance}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => 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 (
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
<InstanceMetadataProvider instance={instance}>
<InstanceShell
instance={instance}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => 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()}
/>
</InstanceMetadataProvider>
</div>
)
</div>
)
}}
</For>

View File

@@ -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<InstanceInfoProps> = (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 (
<div class="panel">
@@ -23,7 +32,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
{props.instance.folder}
{currentInstance().folder}
</div>
</div>
@@ -72,24 +81,24 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</Show>
<Show when={props.instance.binaryPath}>
<Show when={currentInstance().binaryPath}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Binary Path
</div>
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
{props.instance.binaryPath}
{currentInstance().binaryPath}
</div>
</div>
</Show>
<Show when={props.instance.environmentVariables && Object.keys(props.instance.environmentVariables).length > 0}>
<Show when={environmentEntries().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
Environment Variables ({Object.keys(props.instance.environmentVariables!).length})
Environment Variables ({environmentEntries().length})
</div>
<div class="space-y-1">
<For each={Object.entries(props.instance.environmentVariables!)}>
<For each={environmentEntries()}>
{([key, value]) => (
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
@@ -105,11 +114,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</Show>
<InstanceServiceStatus
instanceId={props.instance.id}
initialInstance={props.instance}
class="space-y-3"
/>
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
<Show when={isLoadingMetadata()}>
<div class="text-xs text-muted py-1">
@@ -132,21 +137,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="space-y-1 text-xs">
<div class="flex justify-between items-center">
<span class="text-secondary">Port:</span>
<span class="text-primary font-mono">{props.instance.port}</span>
<span class="text-primary font-mono">{currentInstance().port}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-secondary">PID:</span>
<span class="text-primary font-mono">{props.instance.pid}</span>
<span class="text-primary font-mono">{currentInstance().pid}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-secondary">Status:</span>
<span
class={`status-badge ${props.instance.status}`}
>
<span class={`status-badge ${currentInstance().status}`}>
<div
class={`status-dot ${props.instance.status === "ready" ? "ready" : props.instance.status === "starting" ? "starting" : props.instance.status === "error" ? "error" : "stopped"} ${props.instance.status === "ready" || props.instance.status === "starting" ? "animate-pulse" : ""}`}
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}
/>
{props.instance.status}
{currentInstance().status}
</span>
</div>
</div>

View File

@@ -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<InstanceServiceStatusProps> = (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<ServiceSection[]>(() => 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<Record<string, "connect" | "disconnect">>({})
const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => {
@@ -66,26 +77,8 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (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<InstanceServiceStatusProps> = (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<InstanceServiceStatusProps> = (props) =>
</div>
</Show>
<Show
when={!isLoading() && lspServers().length > 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.")}
>
<div class="space-y-1.5">
<For each={lspServers()}>
@@ -152,8 +145,8 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
</div>
</Show>
<Show
when={!isLoading() && mcpServers().length > 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.")}
>
<div class="space-y-1.5">
<For each={mcpServers()}>
@@ -161,7 +154,7 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (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"

View File

@@ -132,7 +132,6 @@ const InstanceShell2: Component<InstanceShellProps> = (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<InstanceShellProps> = (props) => {
label: "LSP Servers",
render: () => (
<InstanceServiceStatus
instanceId={props.instance.id}
initialInstance={props.instance}
sections={["lsp"]}
showSectionHeadings={false}
@@ -873,7 +871,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
label: "MCP Servers",
render: () => (
<InstanceServiceStatus
instanceId={props.instance.id}
initialInstance={props.instance}
sections={["mcp"]}
showSectionHeadings={false}

View File

@@ -0,0 +1,72 @@
import { Component, JSX, createContext, createEffect, createMemo, createSignal, useContext, type Accessor } from "solid-js"
import type { Instance } from "../../types/instance"
import { instances } from "../../stores/instances"
import { loadInstanceMetadata, hasMetadataLoaded } from "../hooks/use-instance-metadata"
interface InstanceMetadataContextValue {
isLoading: Accessor<boolean>
instance: Accessor<Instance>
metadata: Accessor<Instance["metadata"] | undefined>
refreshMetadata: () => Promise<void>
}
const InstanceMetadataContext = createContext<InstanceMetadataContextValue | null>(null)
interface InstanceMetadataProviderProps {
instance: Instance
children: JSX.Element
}
export const InstanceMetadataProvider: Component<InstanceMetadataProviderProps> = (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 (
<InstanceMetadataContext.Provider value={contextValue}>
{props.children}
</InstanceMetadataContext.Provider>
)
}
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)
}

View File

@@ -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<string>()
export function useInstanceMetadata(instanceAccessor: Accessor<Instance | undefined>) {
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<void> {
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 }