diff --git a/package-lock.json b/package-lock.json index b8c36e94..f6eea0c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1296,6 +1296,16 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", @@ -1531,6 +1541,109 @@ "solid-js": "^1.8.6" } }, + "node_modules/@suid/base": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@suid/base/-/base-0.11.0.tgz", + "integrity": "sha512-jNe+LlXuxfkSZo8/MP9koqYYWswucDWSCwc7ViqUhQ0Y/V7sP2RiQ/Bnms+ePSMBZsk5k1b9fAjvj7DtNbbHXw==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.11.8", + "@suid/css": "0.4.1", + "@suid/system": "0.14.0", + "@suid/types": "0.8.0", + "@suid/utils": "0.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/css": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@suid/css/-/css-0.4.1.tgz", + "integrity": "sha512-Hsi4O3dBOm7rrlqKoWfNoTeRFAXm/7TPaeEmyxNx+wFaT3eROjMVdhadAIiagFT+PsHrq/6fDauUI5TkL+5Zvg==", + "license": "MIT" + }, + "node_modules/@suid/icons-material": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@suid/icons-material/-/icons-material-0.9.0.tgz", + "integrity": "sha512-2idgaT/JARd12dwDfocZBQizaiZVgR0ujRsVc61OlAuPZbeH+3TrSxUJkE3Z7+TPftw9+6p0A24GhJjJLvi6RQ==", + "license": "MIT", + "dependencies": { + "@suid/material": "0.19.0" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/material": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@suid/material/-/material-0.19.0.tgz", + "integrity": "sha512-vfudxYpHdur5CWTjd3eBb7q1b6A9X/pDWTEf2twc0gXVTcErS9VtY/VPBLa65AzO2SPJsdjAE+BCdVZiXASBbA==", + "license": "MIT", + "dependencies": { + "@suid/base": "0.11.0", + "@suid/css": "0.4.1", + "@suid/system": "0.14.0", + "@suid/types": "0.8.0", + "@suid/utils": "0.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/styled-engine": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@suid/styled-engine/-/styled-engine-0.9.0.tgz", + "integrity": "sha512-IfNHjQ3Im63mFIjFl/doiwdn5qbwgcwi/vUXnX7dmIUC/Cw1f3LPhzVT9V8Z3eqyvvFToy53O+BsuLy2e/WmDw==", + "license": "MIT", + "dependencies": { + "@suid/css": "0.4.1", + "@suid/utils": "0.11.0" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/system": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@suid/system/-/system-0.14.0.tgz", + "integrity": "sha512-aRVilPP53hHkqyAyQp2pasT/u8aQCcELwU4kFDnt3b+rj4fsPQRlhMumlX5mZ5aijIboH1CngU6TDG6Z9Mr3UA==", + "license": "MIT", + "dependencies": { + "@suid/css": "0.4.1", + "@suid/styled-engine": "0.9.0", + "@suid/types": "0.8.0", + "@suid/utils": "0.11.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/types": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@suid/types/-/types-0.8.0.tgz", + "integrity": "sha512-/Z2abkbypMjF6ygSpnjqnWohcmPqvgw8Xpx1wPPHeh+LajBP2imNT6uEa5dBqNEkJY8O3wEUCVqErAad/rmn5Q==", + "license": "MIT", + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, + "node_modules/@suid/utils": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@suid/utils/-/utils-0.11.0.tgz", + "integrity": "sha512-dk+6YJkex9kcU2qQHCOk8J0/zkOKKbng0SsjC0LBLyBrf2OC3OtDQq7o22pH3m/8CU/0M6uyM7tnyzZA4eWF3Q==", + "license": "MIT", + "dependencies": { + "@suid/types": "0.8.0" + }, + "peerDependencies": { + "solid-js": "^1.9.7" + } + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -3102,6 +3215,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8895,6 +9017,9 @@ "@kobalte/core": "0.13.11", "@opencode-ai/sdk": "^1.0.138", "@solidjs/router": "^0.13.0", + "@suid/icons-material": "^0.9.0", + "@suid/material": "^0.19.0", + "@suid/system": "^0.14.0", "debug": "^4.4.3", "github-markdown-css": "^5.8.1", "lucide-solid": "^0.300.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index b8314e42..3df9bffc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,6 +14,9 @@ "@kobalte/core": "0.13.11", "@opencode-ai/sdk": "^1.0.138", "@solidjs/router": "^0.13.0", + "@suid/icons-material": "^0.9.0", + "@suid/material": "^0.19.0", + "@suid/system": "^0.14.0", "debug": "^4.4.3", "github-markdown-css": "^5.8.1", "lucide-solid": "^0.300.0", diff --git a/packages/ui/src/components/instance/instance-shell.tsx b/packages/ui/src/components/instance/instance-shell.tsx deleted file mode 100644 index 80949998..00000000 --- a/packages/ui/src/components/instance/instance-shell.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js" -import type { Accessor } from "solid-js" -import type { Instance } from "../../types/instance" -import type { Command } from "../../lib/commands" -import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, 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 } 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 { 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 -} - -const DEFAULT_SESSION_SIDEBAR_WIDTH = 350 -const MOBILE_SIDEBAR_BREAKPOINT = 1024 -const SESSION_CACHE_LIMIT = 2 - -const InstanceShell: Component = (props) => { - const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) - const [isCompactLayout, setIsCompactLayout] = createSignal(false) - const [isSidebarOpen, setIsSidebarOpen] = createSignal(true) - const [cachedSessionIds, setCachedSessionIds] = createSignal([]) - const [pendingEvictions, setPendingEvictions] = createSignal([]) - const sidebarId = `session-sidebar-${props.instance.id}` - let previousIsCompact = false - - const shouldShowSidebarToggle = () => isCompactLayout() && !isSidebarOpen() - - onMount(() => { - if (typeof window === "undefined") return - - const handleResize = () => { - const compact = window.innerWidth < MOBILE_SIDEBAR_BREAKPOINT - setIsCompactLayout(compact) - if (!compact) { - setIsSidebarOpen(true) - } else if (!previousIsCompact && compact) { - setIsSidebarOpen(false) - } - previousIsCompact = compact - } - - handleResize() - window.addEventListener("resize", handleResize) - - onCleanup(() => { - window.removeEventListener("resize", handleResize) - }) - }) - - 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 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 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 - }) - }) - - return ( - <> - 0} fallback={}> -
-
- { - 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 - showFooter={false} - headerContent={ -
-
- Sessions - - - -
-
- {keyboardShortcuts().length ? ( - - ) : null} -
-
- } - onWidthChange={setSessionSidebarWidth} - /> - -
- - {(activeSession) => ( - <> - -
- props.handleSidebarAgentChange(activeSession().id, agent)} - /> - - - - props.handleSidebarModelChange(activeSession().id, model)} - /> - -
- - )} -
-
- -
- - - - 0 && activeSessionIdForInstance()} - fallback={ -
-
-

No session selected

-

Select a session to view messages

-
-
- } - > - - {(sessionId) => { - const isActive = () => activeSessionIdForInstance() === sessionId - return ( -
- setIsSidebarOpen(true)} - forceCompactStatusLayout={shouldShowSidebarToggle()} - isActive={isActive()} - /> -
- ) - }} -
-
- } - > - - -
- - -
- - - hideCommandPalette(props.instance.id)} - commands={instancePaletteCommands()} - onExecute={props.onExecuteCommand} - /> - - ) -} - -export default InstanceShell diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 6c7c401d..84cc2a7b 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -1,15 +1,11 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import Kbd from "./kbd" import MessageBlockList, { getMessageAnchorId } from "./message-block-list" -import MessageListHeader from "./message-list-header" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" -import { showCommandPalette } from "../stores/command-palette" import { messageStoreBus } from "../stores/message-v2/bus" import { useScrollCache } from "../lib/hooks/use-scroll-cache" -import { sseManager } from "../lib/sse-manager" -import { formatTokenTotal } from "../lib/formatters" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" const SCROLL_SCOPE = "session" @@ -19,10 +15,6 @@ const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown" const QUOTE_SELECTION_MAX_LENGTH = 2000 const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href -function formatTokens(tokens: number): string { - return formatTokenTotal(tokens) -} - export interface MessageSectionProps { instanceId: string sessionId: string @@ -77,11 +69,6 @@ export default function MessageSection(props: MessageSectionProps) { return `${showThinking}|${thinkingExpansion}|${showUsage}` }) - const connectionStatus = () => sseManager.getStatus(props.instanceId) - const handleCommandPaletteClick = () => { - showCommandPalette(props.instanceId) - } - const handleTimelineSegmentClick = (segment: TimelineSegment) => { if (typeof document === "undefined") return const anchor = document.getElementById(getMessageAnchorId(segment.messageId)) @@ -757,17 +744,6 @@ export default function MessageSection(props: MessageSectionProps) { return (
- -
diff --git a/packages/ui/src/lib/theme.tsx b/packages/ui/src/lib/theme.tsx index e88f4829..8a2e18f9 100644 --- a/packages/ui/src/lib/theme.tsx +++ b/packages/ui/src/lib/theme.tsx @@ -1,4 +1,6 @@ -import { createContext, createEffect, createSignal, onMount, useContext, type JSX } from "solid-js" +import { createContext, createEffect, createMemo, createSignal, onMount, useContext, type JSX } from "solid-js" +import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/styles" +import CssBaseline from "@suid/material/CssBaseline" import { useConfig } from "../stores/preferences" interface ThemeContextValue { @@ -10,6 +12,7 @@ interface ThemeContextValue { const ThemeContext = createContext() function applyTheme(dark: boolean) { + if (typeof document === "undefined") return if (dark) { document.documentElement.setAttribute("data-theme", "dark") return @@ -18,8 +21,61 @@ function applyTheme(dark: boolean) { document.documentElement.removeAttribute("data-theme") } +interface ResolvedPaletteColors { + backgroundDefault: string + backgroundPaper: string + primary: string + primaryContrast: string + textPrimary: string + textSecondary: string + divider: string +} + +const lightPaletteFallbacks: ResolvedPaletteColors = { + backgroundDefault: "#ffffff", + backgroundPaper: "#f5f5f5", + primary: "#0066ff", + primaryContrast: "#ffffff", + textPrimary: "#1a1a1a", + textSecondary: "#666666", + divider: "#e0e0e0", +} + +const darkPaletteFallbacks: ResolvedPaletteColors = { + backgroundDefault: "#1a1a1a", + backgroundPaper: "#2a2a2a", + primary: "#0080ff", + primaryContrast: "#1a1a1a", + textPrimary: "#cfd4dc", + textSecondary: "#999999", + divider: "#3a3a3a", +} + +const readCssVar = (token: string, fallback: string, rootStyle: CSSStyleDeclaration | null) => { + if (!rootStyle) return fallback + const value = rootStyle.getPropertyValue(token) + if (!value) return fallback + const trimmed = value.trim() + return trimmed || fallback +} + +const resolvePaletteColors = (dark: boolean): ResolvedPaletteColors => { + const fallbackSet = dark ? darkPaletteFallbacks : lightPaletteFallbacks + const rootStyle = typeof window !== "undefined" ? getComputedStyle(document.documentElement) : null + + return { + backgroundDefault: readCssVar("--surface-base", fallbackSet.backgroundDefault, rootStyle), + backgroundPaper: readCssVar("--surface-secondary", fallbackSet.backgroundPaper, rootStyle), + primary: readCssVar("--accent-primary", fallbackSet.primary, rootStyle), + primaryContrast: readCssVar("--text-inverted", fallbackSet.primaryContrast, rootStyle), + textPrimary: readCssVar("--text-primary", fallbackSet.textPrimary, rootStyle), + textSecondary: readCssVar("--text-secondary", fallbackSet.textSecondary, rootStyle), + divider: readCssVar("--border-base", fallbackSet.divider, rootStyle), + } +} + export function ThemeProvider(props: { children: JSX.Element }) { - const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)") + const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null const { themePreference, setThemePreference } = useConfig() const [isDark, setIsDarkSignal] = createSignal(true) @@ -39,14 +95,15 @@ export function ThemeProvider(props: { children: JSX.Element }) { }) onMount(() => { + if (!mediaQuery) return const handleSystemThemeChange = () => { applyResolvedTheme() } - systemPrefersDark.addEventListener("change", handleSystemThemeChange) + mediaQuery.addEventListener("change", handleSystemThemeChange) return () => { - systemPrefersDark.removeEventListener("change", handleSystemThemeChange) + mediaQuery.removeEventListener("change", handleSystemThemeChange) } }) @@ -58,7 +115,72 @@ export function ThemeProvider(props: { children: JSX.Element }) { setTheme(true) } - return {props.children} + const muiTheme = createMemo(() => { + const paletteColors = resolvePaletteColors(isDark()) + return createTheme({ + palette: { + mode: isDark() ? "dark" : "light", + primary: { + main: paletteColors.primary, + contrastText: paletteColors.primaryContrast, + }, + secondary: { + main: paletteColors.primary, + }, + background: { + default: paletteColors.backgroundDefault, + paper: paletteColors.backgroundPaper, + }, + text: { + primary: paletteColors.textPrimary, + secondary: paletteColors.textSecondary, + }, + divider: paletteColors.divider, + }, + typography: { + fontFamily: "var(--font-family-sans)", + }, + shape: { + borderRadius: 8, + }, + components: { + MuiDrawer: { + styleOverrides: { + paper: { + backgroundColor: paletteColors.backgroundPaper, + color: paletteColors.textPrimary, + }, + }, + }, + MuiAppBar: { + styleOverrides: { + root: { + backgroundColor: paletteColors.backgroundPaper, + color: paletteColors.textPrimary, + boxShadow: "none", + borderBottom: `1px solid ${paletteColors.divider}`, + }, + }, + }, + MuiToolbar: { + styleOverrides: { + root: { + minHeight: "56px", + }, + }, + }, + } as any, + }) + }) + + return ( + + + + {props.children} + + + ) } export function useTheme() {