diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx new file mode 100644 index 00000000..4140172f --- /dev/null +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -0,0 +1,907 @@ +import { + For, + Show, + batch, + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, + type Accessor, + type Component, +} from "solid-js" +import AppBar from "@suid/material/AppBar" +import Box from "@suid/material/Box" +import Divider from "@suid/material/Divider" +import Drawer from "@suid/material/Drawer" +import IconButton from "@suid/material/IconButton" +import Toolbar from "@suid/material/Toolbar" +import Typography from "@suid/material/Typography" +import useMediaQuery from "@suid/material/useMediaQuery" +import CloseIcon from "@suid/icons-material/Close" +import MenuIcon from "@suid/icons-material/Menu" +import MenuOpenIcon from "@suid/icons-material/MenuOpen" +import PushPinIcon from "@suid/icons-material/PushPin" +import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" +import type { Instance } from "../../types/instance" +import type { Command } from "../../lib/commands" +import { + activeParentSessionId, + activeSessionId as activeSessionMap, + getSessionFamily, + getSessionInfo, + setActiveSession, +} from "../../stores/sessions" +import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry" +import { messageStoreBus } from "../../stores/message-v2/bus" +import { clearSessionRenderCache } from "../message-block" +import { buildCustomCommandEntries } from "../../lib/command-utils" +import { getCommands as getInstanceCommands } from "../../stores/commands" +import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette" +import SessionList from "../session-list" +import KeyboardHint from "../keyboard-hint" +import InstanceWelcomeView from "../instance-welcome-view" +import InfoView from "../info-view" +import AgentSelector from "../agent-selector" +import ModelSelector from "../model-selector" +import CommandPalette from "../command-palette" +import Kbd from "../kbd" +import ContextUsagePanel from "../session/context-usage-panel" +import SessionView from "../session/session-view" +import { formatTokenTotal } from "../../lib/formatters" +import { sseManager } from "../../lib/sse-manager" +import { getLogger } from "../../lib/logger" + +const log = getLogger("session") + +interface InstanceShellProps { + instance: Instance + escapeInDebounce: boolean + paletteCommands: Accessor + onCloseSession: (sessionId: string) => Promise | void + onNewSession: () => Promise | void + handleSidebarAgentChange: (sessionId: string, agent: string) => Promise + handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise + onExecuteCommand: (command: Command) => void + tabBarOffset: number +} + +const DEFAULT_SESSION_SIDEBAR_WIDTH = 280 +const MIN_SESSION_SIDEBAR_WIDTH = 220 +const MAX_SESSION_SIDEBAR_WIDTH = 360 +const RIGHT_DRAWER_WIDTH = 260 +const SESSION_CACHE_LIMIT = 2 +const APP_BAR_HEIGHT = 56 + + +type LayoutMode = "desktop" | "tablet" | "phone" + +const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) + +const InstanceShell2: Component = (props) => { + const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) + const [leftPinned, setLeftPinned] = createSignal(true) + const [leftOpen, setLeftOpen] = createSignal(true) + const [rightPinned, setRightPinned] = createSignal(true) + const [rightOpen, setRightOpen] = createSignal(true) + const [cachedSessionIds, setCachedSessionIds] = createSignal([]) + const [pendingEvictions, setPendingEvictions] = createSignal([]) + const [drawerHost, setDrawerHost] = createSignal(null) + const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0) + const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0) + const [leftDrawerContentEl, setLeftDrawerContentEl] = createSignal(null) + const [rightDrawerContentEl, setRightDrawerContentEl] = createSignal(null) + const [leftToggleButtonEl, setLeftToggleButtonEl] = createSignal(null) + const [rightToggleButtonEl, setRightToggleButtonEl] = createSignal(null) + + const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) + + + const desktopQuery = useMediaQuery("(min-width: 1280px)") + + const tabletQuery = useMediaQuery("(min-width: 768px)") + + const layoutMode = createMemo(() => { + if (desktopQuery()) return "desktop" + if (tabletQuery()) return "tablet" + return "phone" + }) + + const isPhoneLayout = createMemo(() => layoutMode() === "phone") + + createEffect(() => { + switch (layoutMode()) { + case "desktop": + setLeftPinned(true) + setLeftOpen(true) + setRightPinned(true) + setRightOpen(true) + break + case "tablet": + setLeftPinned(false) + setLeftOpen(false) + setRightPinned(true) + setRightOpen(true) + break + default: + setLeftPinned(false) + setLeftOpen(false) + setRightPinned(false) + setRightOpen(false) + break + } + }) + + const measureDrawerHost = () => { + if (typeof window === "undefined") return + const host = drawerHost() + if (!host) return + const rect = host.getBoundingClientRect() + const toolbar = host.querySelector(".session-toolbar") + const toolbarHeight = toolbar?.offsetHeight ?? APP_BAR_HEIGHT + setFloatingDrawerTop(rect.top + toolbarHeight) + setFloatingDrawerHeight(Math.max(0, rect.height - toolbarHeight)) + } + + onMount(() => { + if (typeof window === "undefined") return + const handleResize = () => { + const width = clampWidth(window.innerWidth * 0.3) + setSessionSidebarWidth((current) => clampWidth(current || width)) + measureDrawerHost() + } + + handleResize() + window.addEventListener("resize", handleResize) + onCleanup(() => window.removeEventListener("resize", handleResize)) + }) + + createEffect(() => { + props.tabBarOffset + requestAnimationFrame(() => measureDrawerHost()) + }) + + const activeSessions = createMemo(() => { + const parentId = activeParentSessionId().get(props.instance.id) + if (!parentId) return new Map[number]>() + const sessionFamily = getSessionFamily(props.instance.id, parentId) + return new Map(sessionFamily.map((s) => [s.id, s])) + }) + + const activeSessionIdForInstance = createMemo(() => { + return activeSessionMap().get(props.instance.id) || null + }) + + const parentSessionIdForInstance = createMemo(() => { + return activeParentSessionId().get(props.instance.id) || null + }) + + const activeSessionForInstance = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") return null + return activeSessions().get(sessionId) ?? null + }) + + const activeSessionUsage = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId) return null + const store = messageStore() + return store?.getSessionUsage(sessionId) ?? null + }) + + const activeSessionInfoDetails = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId) return null + return getSessionInfo(props.instance.id, sessionId) ?? null + }) + + const tokenStats = createMemo(() => { + const usage = activeSessionUsage() + const info = activeSessionInfoDetails() + return { + used: usage?.actualUsageTokens ?? info?.actualUsageTokens ?? 0, + avail: info?.contextAvailableTokens ?? null, + } + }) + + const connectionStatus = () => sseManager.getStatus(props.instance.id) + const connectionStatusClass = () => { + const status = connectionStatus() + if (status === "connecting") return "connecting" + if (status === "connected") return "connected" + return "disconnected" + } + + const handleCommandPaletteClick = () => { + showCommandPalette(props.instance.id) + } + + const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id))) + + const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()]) + const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id)) + + const keyboardShortcuts = createMemo(() => + [keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter( + (shortcut): shortcut is KeyboardShortcut => Boolean(shortcut), + ), + ) + + const handleSessionSelect = (sessionId: string) => { + setActiveSession(props.instance.id, sessionId) + } + + const handleSidebarWidthChange = (nextWidth: number) => { + setSessionSidebarWidth(clampWidth(nextWidth)) + } + + const evictSession = (sessionId: string) => { + if (!sessionId) return + log.info("Evicting cached session", { instanceId: props.instance.id, sessionId }) + const store = messageStoreBus.getInstance(props.instance.id) + store?.clearSession(sessionId) + clearSessionRenderCache(props.instance.id, sessionId) + } + + const scheduleEvictions = (ids: string[]) => { + if (!ids.length) return + setPendingEvictions((current) => { + const existing = new Set(current) + const next = [...current] + ids.forEach((id) => { + if (!existing.has(id)) { + next.push(id) + existing.add(id) + } + }) + return next + }) + } + + createEffect(() => { + const pending = pendingEvictions() + if (!pending.length) return + const cached = new Set(cachedSessionIds()) + const remaining: string[] = [] + pending.forEach((id) => { + if (cached.has(id)) { + remaining.push(id) + } else { + evictSession(id) + } + }) + if (remaining.length !== pending.length) { + setPendingEvictions(remaining) + } + }) + + createEffect(() => { + const sessionsMap = activeSessions() + const parentId = parentSessionIdForInstance() + const activeId = activeSessionIdForInstance() + setCachedSessionIds((current) => { + const next: string[] = [] + const append = (id: string | null) => { + if (!id || id === "info") return + if (!sessionsMap.has(id)) return + if (next.includes(id)) return + next.push(id) + } + + append(parentId) + append(activeId) + current.forEach((id) => append(id)) + + const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT + const trimmed = next.length > limit ? next.slice(0, limit) : next + const trimmedSet = new Set(trimmed) + const removed = current.filter((id) => !trimmedSet.has(id)) + if (removed.length) { + scheduleEvictions(removed) + } + return trimmed + }) + }) + + const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen()) + + const drawerContainer = () => { + const host = drawerHost() + if (host) return host + if (typeof document !== "undefined") { + return document.body + } + return undefined + } + + const fallbackDrawerTop = () => APP_BAR_HEIGHT + props.tabBarOffset + const floatingTop = () => { + const measured = floatingDrawerTop() + if (measured > 0) return measured + return fallbackDrawerTop() + } + const floatingTopPx = () => `${floatingTop()}px` + const floatingHeight = () => { + const measured = floatingDrawerHeight() + if (measured > 0) return `${measured}px` + return `calc(100% - ${floatingTop()}px)` + } + + type DrawerViewState = "pinned" | "floating-open" | "floating-closed" + + const leftDrawerState = createMemo(() => { + if (leftPinned()) return "pinned" + return leftOpen() ? "floating-open" : "floating-closed" + }) + + const rightDrawerState = createMemo(() => { + if (rightPinned()) return "pinned" + return rightOpen() ? "floating-open" : "floating-closed" + }) + + const leftAppBarButtonLabel = () => { + const state = leftDrawerState() + if (state === "pinned") return "Left drawer pinned" + if (state === "floating-closed") return "Open left drawer" + return "Close left drawer" + } + + const rightAppBarButtonLabel = () => { + const state = rightDrawerState() + if (state === "pinned") return "Right drawer pinned" + if (state === "floating-closed") return "Open right drawer" + return "Close right drawer" + } + + const leftAppBarButtonIcon = () => { + const state = leftDrawerState() + if (state === "floating-closed") return + return + } + + const rightAppBarButtonIcon = () => { + const state = rightDrawerState() + if (state === "floating-closed") return + return + } + + + + + const pinLeftDrawer = () => { + + blurIfInside(leftDrawerContentEl()) + batch(() => { + setLeftPinned(true) + setLeftOpen(true) + }) + measureDrawerHost() + } + + const unpinLeftDrawer = () => { + blurIfInside(leftDrawerContentEl()) + batch(() => { + setLeftPinned(false) + setLeftOpen(true) + }) + measureDrawerHost() + } + + const pinRightDrawer = () => { + blurIfInside(rightDrawerContentEl()) + batch(() => { + setRightPinned(true) + setRightOpen(true) + }) + measureDrawerHost() + } + + const unpinRightDrawer = () => { + blurIfInside(rightDrawerContentEl()) + batch(() => { + setRightPinned(false) + setRightOpen(true) + }) + measureDrawerHost() + } + + const handleLeftAppBarButtonClick = () => { + const state = leftDrawerState() + if (state === "pinned") return + if (state === "floating-closed") { + setLeftOpen(true) + measureDrawerHost() + return + } + blurIfInside(leftDrawerContentEl()) + setLeftOpen(false) + focusTarget(leftToggleButtonEl()) + measureDrawerHost() + } + + const handleRightAppBarButtonClick = () => { + const state = rightDrawerState() + if (state === "pinned") return + if (state === "floating-closed") { + setRightOpen(true) + measureDrawerHost() + return + } + blurIfInside(rightDrawerContentEl()) + setRightOpen(false) + focusTarget(rightToggleButtonEl()) + measureDrawerHost() + } + + + const focusTarget = (element: HTMLElement | null) => { + if (!element) return + requestAnimationFrame(() => { + element.focus() + }) + } + + const blurIfInside = (element: HTMLElement | null) => { + if (typeof document === "undefined" || !element) return + const active = document.activeElement as HTMLElement | null + if (active && element.contains(active)) { + active.blur() + } + } + + const closeLeftDrawer = () => { + if (leftDrawerState() === "pinned") return + blurIfInside(leftDrawerContentEl()) + setLeftOpen(false) + focusTarget(leftToggleButtonEl()) + } + const closeRightDrawer = () => { + if (rightDrawerState() === "pinned") return + blurIfInside(rightDrawerContentEl()) + setRightOpen(false) + focusTarget(rightToggleButtonEl()) + } + + const formattedUsedTokens = () => formatTokenTotal(tokenStats().used) + + + const formattedAvailableTokens = () => { + const avail = tokenStats().avail + if (typeof avail === "number") { + return formatTokenTotal(avail) + } + return "--" + } + + const LeftDrawerContent = () => ( +
+
+
+ Sessions +
+ + + +
+
+
+ (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} + > + {leftPinned() ? : } + +
+ +
+ +
+ { + const result = props.onCloseSession(id) + if (result instanceof Promise) { + void result.catch((error) => log.error("Failed to close session:", error)) + } + }} + onNew={() => { + const result = props.onNewSession() + if (result instanceof Promise) { + void result.catch((error) => log.error("Failed to create session:", error)) + } + }} + showHeader={false} + showFooter={false} + onWidthChange={handleSidebarWidthChange} + /> + + + + {(activeSession) => ( + <> + +
+ props.handleSidebarAgentChange(activeSession().id, agent)} + /> + + + + props.handleSidebarModelChange(activeSession().id, model)} + /> +
+ + )} +
+
+
+ ) + + const RightDrawerContent = () => ( +
+
+ + Side Panel + +
+ (rightPinned() ? unpinRightDrawer() : pinRightDrawer())} + > + {rightPinned() ? : } + +
+
+
+
+ ) + + const renderLeftPanel = () => { + if (leftPinned()) { + return ( + + + + ) + } + const container = drawerContainer() + const modalProps = container ? { container: container as Element } : undefined + return ( + + + + ) + } + + + const renderRightPanel = () => { + if (rightPinned()) { + return ( + + + + ) + } + const container = drawerContainer() + const modalProps = container ? { container: container as Element } : undefined + return ( + + + + + ) + } + + const hasSessions = createMemo(() => activeSessions().size > 0) + + const showingInfoView = createMemo(() => activeSessionIdForInstance() === "info") + + const sessionLayout = ( +
{ + setDrawerHost(element) + measureDrawerHost() + }} + > + + + +
+ + {leftAppBarButtonIcon()} + + +
+ + + + + + + +
+ + + {rightAppBarButtonIcon()} + +
+ +
+
+ Used + {formattedUsedTokens()} +
+
+ Avail + {formattedAvailableTokens()} +
+
+
+ } + > +
+ + {leftAppBarButtonIcon()} + + +
+ Used + {formattedUsedTokens()} +
+
+ Avail + {formattedAvailableTokens()} +
+
+ +
+ + + + +
+ +
+
+ + + + Connected + + + + + + Connecting... + + + + + + Disconnected + + +
+ + {rightAppBarButtonIcon()} + +
+ + + + + + {renderLeftPanel()} + + + 0 && activeSessionIdForInstance()} + fallback={ +
+
+

No session selected

+

Select a session to view messages

+
+
+ } + > + + {(sessionId) => { + const isActive = () => activeSessionIdForInstance() === sessionId + return ( +
+ setLeftOpen(true)} + forceCompactStatusLayout={showEmbeddedSidebarToggle()} + isActive={isActive()} + /> +
+ ) + }} +
+
+
+ + {renderRightPanel()} +
+
+ ) + + return ( + <> +
+ }> + + + + +
+ + hideCommandPalette(props.instance.id)} + commands={instancePaletteCommands()} + onExecute={props.onExecuteCommand} + /> + + ) +} + +export default InstanceShell2