Merge branch 'Pouzor:main' into main

This commit is contained in:
Brett Ferrante
2026-04-23 19:40:14 -04:00
committed by GitHub
2 changed files with 61 additions and 21 deletions
+48 -14
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useRef, useState, type KeyboardEvent } from 'react'
import { toast } from 'sonner'
import { Check } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -14,17 +14,21 @@ interface ThemeCardProps {
themeId: ThemeId
selected: boolean
onClick: () => void
onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void
buttonRef?: (element: HTMLButtonElement | null) => void
}
function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef }: ThemeCardProps) {
const preset = THEMES[themeId]
const c = preset.colors
return (
<button
ref={buttonRef}
type="button"
onClick={onClick}
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
onKeyDown={onKeyDown}
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
style={{
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
background: c.canvasBackground,
@@ -70,13 +74,13 @@ function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
{/* Label */}
<div
className="text-xs font-semibold leading-tight"
className="text-sm font-semibold leading-tight wrap-break-word"
style={{ color: c.nodeLabelColor }}
>
{preset.label}
</div>
<div
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12"
style={{ color: c.nodeSubtextColor }}
>
{preset.description}
@@ -93,6 +97,7 @@ interface ThemeModalProps {
export function ThemeModal({ open, onClose }: ThemeModalProps) {
const { activeTheme, setTheme } = useThemeStore()
const { markUnsaved } = useCanvasStore()
const cardRefs = useRef<Array<HTMLButtonElement | null>>([])
// Capture the theme that was active when the modal opened
const [originalTheme] = useState<ThemeId>(activeTheme)
@@ -104,6 +109,30 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
setTheme(id)
}
const handleCardKeyDown = (index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
handleApply()
return
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
const nextIndex = (index + direction + THEME_ORDER.length) % THEME_ORDER.length
const nextTheme = THEME_ORDER[nextIndex]
setSelected(nextTheme)
setTheme(nextTheme)
const nextCard = cardRefs.current[nextIndex]
if (!nextCard) return
nextCard.focus({ preventScroll: true })
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
const handleApply = () => {
setTheme(selected)
markUnsaved()
@@ -121,19 +150,24 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
<DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-5 gap-3 py-1">
{THEME_ORDER.map((id) => (
<ThemeCard
key={id}
themeId={id}
selected={selected === id}
onClick={() => handleSelect(id)}
/>
<div className="flex items-stretch flex-nowrap gap-3 py-1 overflow-x-auto overflow-y-hidden pb-2 pr-1">
{THEME_ORDER.map((id, index) => (
<div key={id} className="shrink-0 w-30 md:w-24 h-full">
<ThemeCard
themeId={id}
selected={selected === id}
onClick={() => handleSelect(id)}
onKeyDown={handleCardKeyDown(index)}
buttonRef={(element) => {
cardRefs.current[index] = element
}}
/>
</div>
))}
</div>
+13 -7
View File
@@ -1,7 +1,8 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import FastAPI
from mcp.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.routing import Mount
from .auth import ApiKeyMiddleware
from .backend_client import backend
@@ -28,15 +29,20 @@ async def lifespan(app: FastAPI):
await backend.stop()
app = FastAPI(title="Homelable MCP", lifespan=lifespan)
# Mount the session manager as an ASGI sub-app instead of wrapping it in a
# FastAPI @app.api_route handler. Wrapping it in a route handler causes
# FastAPI to send http.response.start after the session manager has already
# started the response, raising `RuntimeError: Unexpected ASGI message
# 'http.response.start' sent, after response already completed` on every
# POST /mcp — which makes the server unreachable from any MCP client.
app = FastAPI(
title="Homelable MCP",
lifespan=lifespan,
routes=[Mount("/mcp", app=session_manager.handle_request)],
)
app.add_middleware(ApiKeyMiddleware)
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
async def mcp_endpoint(request: Request):
await session_manager.handle_request(request.scope, request.receive, request._send)
@app.get("/health")
async def health():
return {"status": "ok"}