diff --git a/packages/ui/src/components/instance-info.tsx b/packages/ui/src/components/instance-info.tsx index 4d3a2ce2..9ea82f4c 100644 --- a/packages/ui/src/components/instance-info.tsx +++ b/packages/ui/src/components/instance-info.tsx @@ -1,194 +1,18 @@ -import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js" -import Switch from "@suid/material/Switch" -import type { Instance, RawMcpStatus } from "../types/instance" -import { fetchLspStatus, updateInstance } from "../stores/instances" -import { getLogger } from "../lib/logger" - -const log = getLogger("session") +import { Component, For, Show } from "solid-js" +import type { Instance } from "../types/instance" +import { useInstanceMetadata } from "../lib/hooks/use-instance-metadata" +import InstanceServiceStatus from "./instance-service-status" interface InstanceInfoProps { instance: Instance compact?: boolean } -type ParsedMcpStatus = { - name: string - status: "running" | "stopped" | "error" - error?: string -} - -function parseMcpStatus(status: RawMcpStatus): ParsedMcpStatus[] { - if (!status || typeof status !== "object") return [] - - const result: ParsedMcpStatus[] = [] - - for (const [name, value] of Object.entries(status)) { - if (!value || typeof value !== "object") continue - const rawStatus = (value as { status?: string }).status - if (!rawStatus) continue - - let mappedStatus: ParsedMcpStatus["status"] - if (rawStatus === "connected") { - mappedStatus = "running" - } else if (rawStatus === "failed") { - mappedStatus = "error" - } else { - mappedStatus = "stopped" - } - - result.push({ - name, - status: mappedStatus, - error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined, - }) - } - - return result -} - -const pendingMetadataRequests = new Set() - const InstanceInfo: Component = (props) => { - const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true) - const [pendingMcpActions, setPendingMcpActions] = createSignal>({}) + const { isLoading: isLoadingMetadata } = useInstanceMetadata(() => props.instance) const metadata = () => props.instance.metadata const binaryVersion = () => props.instance.binaryVersion || metadata()?.version - const mcpServers = () => { - const status = metadata()?.mcpStatus - return status ? parseMcpStatus(status) : [] - } - const lspServers = () => metadata()?.lspStatus ?? [] - - const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => { - setPendingMcpActions((prev) => { - const next = { ...prev } - if (action) { - next[name] = action - } else { - delete next[name] - } - return next - }) - } - - const refreshMcpStatus = async () => { - const client = props.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.instance.id, { - metadata: { - ...(props.instance.metadata ?? {}), - mcpStatus: status, - }, - }) - } catch (error) { - log.error("Failed to refresh MCP status", error) - } - } - - const toggleMcpServer = async (serverName: string, shouldEnable: boolean) => { - const client = props.instance.client - if (!client?.mcp) { - return - } - - const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect" - setPendingMcpAction(serverName, action) - - try { - if (shouldEnable) { - await client.mcp.connect({ path: { name: serverName } }) - } else { - await client.mcp.disconnect({ path: { name: serverName } }) - } - await refreshMcpStatus() - } catch (error) { - log.error("Failed to toggle MCP server", { serverName, action, error }) - } finally { - setPendingMcpAction(serverName) - } - } - - createEffect(() => { - const instance = props.instance - const instanceId = instance.id - const client = instance.client - const hasMetadata = Boolean(instance.metadata) - - if (!client) { - setIsLoadingMetadata(false) - pendingMetadataRequests.delete(instanceId) - return - } - - if (hasMetadata) { - setIsLoadingMetadata(false) - pendingMetadataRequests.delete(instanceId) - return - } - - if (pendingMetadataRequests.has(instanceId)) { - setIsLoadingMetadata(true) - return - } - - let cancelled = false - pendingMetadataRequests.add(instanceId) - setIsLoadingMetadata(true) - - 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 ?? {}), - ...(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) { - setIsLoadingMetadata(false) - } - } - })() - - onCleanup(() => { - cancelled = true - }) - }) return (
@@ -281,109 +105,11 @@ const InstanceInfo: Component = (props) => {
- 0}> -
-
- LSP Servers -
-
- - {(server) => ( -
-
-
- {server.name ?? server.id} - - {server.root} - -
-
-
- {server.status === "connected" ? "Connected" : "Error"} -
-
-
- )} - -
-
- - - 0}> -
-
- MCP Servers -
-
- - {(server) => { - const pendingAction = pendingMcpActions()[server.name] - const isPending = Boolean(pendingAction) - const isRunning = server.status === "running" - const switchDisabled = isPending || !props.instance.client - - const statusDotClass = () => { - if (isPending) { - return "status-dot animate-pulse" - } - if (server.status === "running") { - return "status-dot ready animate-pulse" - } - if (server.status === "error") { - return "status-dot error" - } - return "status-dot stopped" - } - - const statusDotStyle = () => (isPending ? { background: "var(--status-warning)" } : undefined) - - return ( -
-
- {server.name} -
-
-
-
-
- { - if (switchDisabled) return - void toggleMcpServer(server.name, Boolean(checked)) - }} - /> - - - - - - -
-
-
- - {(error) => ( -
- {error()} -
- )} -
-
- ) - }} - -
-
- +
diff --git a/packages/ui/src/components/instance-service-status.tsx b/packages/ui/src/components/instance-service-status.tsx new file mode 100644 index 00000000..cf7a198e --- /dev/null +++ b/packages/ui/src/components/instance-service-status.tsx @@ -0,0 +1,229 @@ +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 { getLogger } from "../lib/logger" + +const log = getLogger("session") + +type ServiceSection = "lsp" | "mcp" + +interface InstanceServiceStatusProps { + instanceId: string + initialInstance?: Instance + sections?: ServiceSection[] + showSectionHeadings?: boolean + class?: string +} + +type ParsedMcpStatus = { + name: string + status: "running" | "stopped" | "error" + error?: string +} + +function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] { + if (!status || typeof status !== "object") return [] + const result: ParsedMcpStatus[] = [] + for (const [name, value] of Object.entries(status)) { + if (!value || typeof value !== "object") continue + const rawStatus = (value as { status?: string }).status + if (!rawStatus) continue + let mapped: ParsedMcpStatus["status"] + if (rawStatus === "connected") mapped = "running" + else if (rawStatus === "failed") mapped = "error" + else mapped = "stopped" + result.push({ + name, + status: mapped, + error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined, + }) + } + return result +} + +const InstanceServiceStatus: Component = (props) => { + const instance = createMemo(() => instances().get(props.instanceId) ?? props.initialInstance) + 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 lspServers = createMemo(() => metadata()?.lspStatus ?? []) + const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus)) + + const [pendingMcpActions, setPendingMcpActions] = createSignal>({}) + + const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => { + setPendingMcpActions((prev) => { + const next = { ...prev } + if (action) next[name] = action + else delete next[name] + return next + }) + } + + 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 + if (!client?.mcp) return + const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect" + setPendingMcpAction(serverName, action) + try { + if (shouldEnable) { + await client.mcp.connect({ path: { name: serverName } }) + } else { + await client.mcp.disconnect({ path: { name: serverName } }) + } + await refreshMcpStatus() + } catch (error) { + log.error("Failed to toggle MCP server", { serverName, action, error }) + } finally { + setPendingMcpAction(serverName) + } + } + + const renderEmptyState = (message: string) => ( +

+ {message} +

+ ) + + const renderLspSection = () => ( +
+ +
+ LSP Servers +
+
+ 0} + fallback={renderEmptyState(isLoading() ? "Loading server status..." : "No LSP servers detected.")} + > +
+ + {(server) => ( +
+
+
+ {server.name ?? server.id} + + {server.root} + +
+
+
+ {server.status === "connected" ? "Connected" : "Error"} +
+
+
+ )} + +
+ +
+ ) + + const renderMcpSection = () => ( +
+ +
+ MCP Servers +
+
+ 0} + fallback={renderEmptyState(isLoading() ? "Loading server status..." : "No MCP servers detected.")} + > +
+ + {(server) => { + const pendingAction = () => pendingMcpActions()[server.name] + const isPending = () => Boolean(pendingAction()) + const isRunning = () => server.status === "running" + const switchDisabled = () => isPending() || !instance()?.client + const statusDotClass = () => { + if (isPending()) return "status-dot animate-pulse" + if (server.status === "running") return "status-dot ready animate-pulse" + if (server.status === "error") return "status-dot error" + return "status-dot stopped" + } + const statusDotStyle = () => (isPending() ? { background: "var(--status-warning)" } : undefined) + return ( +
+
+ {server.name} +
+
+
+
+
+ { + if (switchDisabled()) return + void toggleMcpServer(server.name, Boolean(checked)) + }} + /> + + + + + + +
+
+
+ + {(error) => ( +
+ {error()} +
+ )} +
+
+ ) + }} + +
+ +
+ ) + + return ( +
+ {renderLspSection()} + {renderMcpSection()} +
+ ) +} + +export default InstanceServiceStatus diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 43747c15..63616545 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -10,6 +10,8 @@ import { type Accessor, type Component, } from "solid-js" +import { Accordion } from "@kobalte/core" +import { ChevronDown } from "lucide-solid" import AppBar from "@suid/material/AppBar" import Box from "@suid/material/Box" import Divider from "@suid/material/Divider" @@ -42,6 +44,7 @@ import SessionList from "../session-list" import KeyboardHint from "../keyboard-hint" import InstanceWelcomeView from "../instance-welcome-view" import InfoView from "../info-view" +import InstanceServiceStatus from "../instance-service-status" import AgentSelector from "../agent-selector" import ModelSelector from "../model-selector" import CommandPalette from "../command-palette" @@ -118,6 +121,7 @@ const InstanceShell2: Component = (props) => { const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null) const [resizeStartX, setResizeStartX] = createSignal(0) const [resizeStartWidth, setResizeStartWidth] = createSignal(0) + const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal(["lsp", "mcp"]) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) @@ -704,28 +708,94 @@ const InstanceShell2: Component = (props) => {
) - const RightDrawerContent = () => ( -
-
- - Side Panel - -
- - (rightPinned() ? unpinRightDrawer() : pinRightDrawer())} - > - {rightPinned() ? : } - - + const RightDrawerContent = () => { + const sections = [ + { + id: "lsp", + label: "LSP Servers", + content: ( + + ), + }, + { + id: "mcp", + label: "MCP Servers", + content: ( + + ), + }, + ] + + const handleAccordionChange = (values: string[]) => { + setRightPanelExpandedItems(values) + } + + const isSectionExpanded = (id: string) => rightPanelExpandedItems().includes(id) + + return ( +
+
+ + Control Panel + +
+ + (rightPinned() ? unpinRightDrawer() : pinRightDrawer())} + > + {rightPinned() ? : } + + +
+
+
+ + + {(section) => ( + + + + {section.label} + + + + + {section.content} + + + )} + +
-
-
- ) + ) + } const renderLeftPanel = () => { if (leftPinned()) { diff --git a/packages/ui/src/lib/hooks/use-instance-metadata.ts b/packages/ui/src/lib/hooks/use-instance-metadata.ts new file mode 100644 index 00000000..cd538e29 --- /dev/null +++ b/packages/ui/src/lib/hooks/use-instance-metadata.ts @@ -0,0 +1,92 @@ +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" + +const log = getLogger("session") +const pendingMetadataRequests = new Set() + +export function useInstanceMetadata(instanceAccessor: Accessor) { + const [isLoading, setIsLoading] = createSignal(true) + + createEffect(() => { + const instance = instanceAccessor() + if (!instance) { + setIsLoading(false) + return + } + + const instanceId = instance.id + const client = instance.client + const hasMetadata = Boolean(instance.metadata) + + if (!client) { + setIsLoading(false) + pendingMetadataRequests.delete(instanceId) + return + } + + if (hasMetadata) { + setIsLoading(false) + pendingMetadataRequests.delete(instanceId) + return + } + + if (pendingMetadataRequests.has(instanceId)) { + setIsLoading(true) + return + } + + let cancelled = false + pendingMetadataRequests.add(instanceId) + setIsLoading(true) + + 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, + } +}