Add control panel accordion for session sidebar

This commit is contained in:
Shantur Rathore
2025-12-14 14:09:07 +00:00
parent 7591e5c1c9
commit a6404f25d9
4 changed files with 421 additions and 304 deletions

View File

@@ -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<string>()
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
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 (
<div class="panel">
@@ -281,109 +105,11 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</Show>
<Show when={!isLoadingMetadata() && lspServers().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
LSP Servers
</div>
<div class="space-y-1.5">
<For each={lspServers()}>
{(server) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
<span class="text-[11px] text-secondary truncate" title={server.root}>
{server.root}
</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
MCP Servers
</div>
<div class="space-y-1.5">
<For each={mcpServers()}>
{(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 (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
<div class="flex items-center gap-3 flex-shrink-0">
<div class="flex items-center gap-1.5 text-xs text-secondary">
<div class={statusDotClass()} style={statusDotStyle()} />
</div>
<div class="flex items-center gap-1.5">
<Switch
checked={isRunning}
disabled={switchDisabled}
color="success"
size="small"
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
onChange={(_, checked) => {
if (switchDisabled) return
void toggleMcpServer(server.name, Boolean(checked))
}}
/>
<Show when={isPending}>
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</Show>
</div>
</div>
</div>
<Show when={server.error}>
{(error) => (
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
{error()}
</div>
)}
</Show>
</div>
)
}}
</For>
</div>
</div>
</Show>
<InstanceServiceStatus
instanceId={props.instance.id}
initialInstance={props.instance}
class="space-y-3"
/>
<Show when={isLoadingMetadata()}>
<div class="text-xs text-muted py-1">

View File

@@ -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<InstanceServiceStatusProps> = (props) => {
const instance = createMemo(() => instances().get(props.instanceId) ?? props.initialInstance)
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 lspServers = createMemo(() => metadata()?.lspStatus ?? [])
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus))
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
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) => (
<p class="text-[11px] text-secondary italic" role="status">
{message}
</p>
)
const renderLspSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
LSP Servers
</div>
</Show>
<Show
when={!isLoading() && lspServers().length > 0}
fallback={renderEmptyState(isLoading() ? "Loading server status..." : "No LSP servers detected.")}
>
<div class="space-y-1.5">
<For each={lspServers()}>
{(server) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
<span class="text-[11px] text-secondary truncate" title={server.root}>
{server.root}
</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
</section>
)
const renderMcpSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
MCP Servers
</div>
</Show>
<Show
when={!isLoading() && mcpServers().length > 0}
fallback={renderEmptyState(isLoading() ? "Loading server status..." : "No MCP servers detected.")}
>
<div class="space-y-1.5">
<For each={mcpServers()}>
{(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 (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
<div class="flex items-center gap-3 flex-shrink-0">
<div class="flex items-center gap-1.5 text-xs text-secondary">
<div class={statusDotClass()} style={statusDotStyle()} />
</div>
<div class="flex items-center gap-1.5">
<Switch
checked={isRunning()}
disabled={switchDisabled()}
color="success"
size="small"
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
onChange={(_, checked) => {
if (switchDisabled()) return
void toggleMcpServer(server.name, Boolean(checked))
}}
/>
<Show when={isPending()}>
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</Show>
</div>
</div>
</div>
<Show when={server.error}>
{(error) => (
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
{error()}
</div>
)}
</Show>
</div>
)
}}
</For>
</div>
</Show>
</section>
)
return (
<div class={props.class}>
<Show when={includeLsp()}>{renderLspSection()}</Show>
<Show when={includeMcp()}>{renderMcpSection()}</Show>
</div>
)
}
export default InstanceServiceStatus

View File

@@ -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<InstanceShellProps> = (props) => {
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null)
const [resizeStartX, setResizeStartX] = createSignal(0)
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp"])
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
@@ -704,28 +708,94 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div>
)
const RightDrawerContent = () => (
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
<div class="flex items-center justify-between px-4 py-3 border-b border-base">
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold">
Side Panel
</Typography>
<div class="flex items-center gap-2">
<Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={rightPinned() ? "Unpin right drawer" : "Pin right drawer"}
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
>
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
const RightDrawerContent = () => {
const sections = [
{
id: "lsp",
label: "LSP Servers",
content: (
<InstanceServiceStatus
instanceId={props.instance.id}
initialInstance={props.instance}
sections={["lsp"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
{
id: "mcp",
label: "MCP Servers",
content: (
<InstanceServiceStatus
instanceId={props.instance.id}
initialInstance={props.instance}
sections={["mcp"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
]
const handleAccordionChange = (values: string[]) => {
setRightPanelExpandedItems(values)
}
const isSectionExpanded = (id: string) => rightPanelExpandedItems().includes(id)
return (
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
<div class="flex items-center justify-between px-4 py-2 border-b border-base">
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold">
Control Panel
</Typography>
<div class="flex items-center gap-2">
<Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={rightPinned() ? "Unpin right drawer" : "Pin right drawer"}
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
>
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<Accordion.Root
class="flex flex-col"
collapsible
multiple
value={rightPanelExpandedItems()}
onChange={handleAccordionChange}
>
<For each={sections}>
{(section) => (
<Accordion.Item
value={section.id}
class="w-full border border-base bg-surface-secondary text-primary"
>
<Accordion.Header>
<Accordion.Trigger class="w-full flex items-center justify-between gap-3 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide">
<span>{section.label}</span>
<ChevronDown
class={`h-4 w-4 transition-transform duration-150 ${isSectionExpanded(section.id) ? "rotate-180" : ""}`}
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="w-full px-3 pb-3 text-sm text-primary">
{section.content}
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion.Root>
</div>
</div>
<div class="flex-1" />
</div>
)
)
}
const renderLeftPanel = () => {
if (leftPinned()) {

View File

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