diff --git a/README.md b/README.md index eca2306f..1c8916a3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# OpenChamber +# OpenChamber [![GitHub stars](https://img.shields.io/github/stars/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/btriapitsyn/openchamber/stargazers) [![GitHub forks](https://img.shields.io/github/forks/btriapitsyn/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik04OCw2NEEyNCwyNCwwLDEsMSw2NCw0MCwyNCwyNCwwLDAsMSw4OCw2NFpNMTkyLDQwYTI0LDI0LDAsMSwwLDI0LDI0QTI0LDI0LDAsMCwwLDE5Miw0MFoiIG9wYWNpdHk9IjAuMiI%2BPC9wYXRoPjxwYXRoIGQ9Ik0yMjQsNjRhMzIsMzIsMCwxLDAtNDAsMzF2MTdhOCw4LDAsMCwxLTgsOEg4MGE4LDgsMCwwLDEtOC04Vjk1YTMyLDMyLDAsMSwwLTE2LDB2MTdhMjQsMjQsMCwwLDAsMjQsMjRoNDB2MjVhMzIsMzIsMCwxLDAsMTYsMFYxMzZoNDBhMjQsMjQsMCwwLDAsMjQtMjRWOTVBMzIuMDYsMzIuMDYsMCwwLDAsMjI0LDY0Wk00OCw2NEExNiwxNiwwLDEsMSw2NCw4MCwxNiwxNiwwLDAsMSw0OCw2NFptOTYsMTI4YTE2LDE2LDAsMSwxLTE2LTE2QTE2LDE2LDAsMCwxLDE0NCwxOTJaTTE5Miw4MGExNiwxNiwwLDEsMSwxNi0xNkExNiwxNiwwLDAsMSwxOTIsODBaIj48L3BhdGg%2BPC9zdmc%2B&logoColor=FFFCF0&labelColor=100F0F&color=BC5215)](https://github.com/btriapitsyn/openchamber/network/members) diff --git a/docs/references/badges/openchamber-logo-dark.svg b/docs/references/badges/openchamber-logo-dark.svg new file mode 100644 index 00000000..9b275b16 --- /dev/null +++ b/docs/references/badges/openchamber-logo-dark.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/references/badges/openchamber-logo-light.svg b/docs/references/badges/openchamber-logo-light.svg new file mode 100644 index 00000000..5130b2f3 --- /dev/null +++ b/docs/references/badges/openchamber-logo-light.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/desktop/index.html b/packages/desktop/index.html index 500363fb..58daab29 100644 --- a/packages/desktop/index.html +++ b/packages/desktop/index.html @@ -54,39 +54,85 @@ font-family: 'IBM Plex Mono', monospace; background-color: transparent; } - /* Theme-aware text colors for loading screen */ - html.dark body { - color: #CECDC3; /* Flexoki dark foreground */ + /* Theme-aware colors for loading screen */ + html.dark { + --splash-stroke: white; + --splash-face-fill: rgba(255, 255, 255, 0.15); + --splash-cell-fill: rgba(255, 255, 255, 0.35); + --splash-logo-fill: white; } - html.light body { - color: #100F0F; /* Flexoki light foreground */ + html.light { + --splash-stroke: black; + --splash-face-fill: rgba(0, 0, 0, 0.15); + --splash-cell-fill: rgba(0, 0, 0, 0.4); + --splash-logo-fill: black; } .loading { display: flex; align-items: center; justify-content: center; height: 100%; - font-size: 2.5rem; - font-weight: 600; - letter-spacing: 0.05em; } - .loading span { - display: inline-block; - opacity: 0; - transform: translateY(8px); - animation: slideUp 0.5s ease-out forwards; + @keyframes logo-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } } - @keyframes slideUp { - to { - opacity: 1; - transform: translateY(0); - } + .logo-pulse { + animation: logo-pulse 3s ease-in-out infinite; }
-
OpenChamber
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/packages/desktop/src-tauri/icons/app-icon.png b/packages/desktop/src-tauri/icons/app-icon.png index 2ca31187..5e4087d0 100644 Binary files a/packages/desktop/src-tauri/icons/app-icon.png and b/packages/desktop/src-tauri/icons/app-icon.png differ diff --git a/packages/desktop/src-tauri/icons/app-icon.svg b/packages/desktop/src-tauri/icons/app-icon.svg index 0fb426bf..5f949ad4 100644 --- a/packages/desktop/src-tauri/icons/app-icon.svg +++ b/packages/desktop/src-tauri/icons/app-icon.svg @@ -1,35 +1,67 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/desktop/src-tauri/icons/icon-rgba.png b/packages/desktop/src-tauri/icons/icon-rgba.png deleted file mode 100644 index 11311999..00000000 Binary files a/packages/desktop/src-tauri/icons/icon-rgba.png and /dev/null differ diff --git a/packages/desktop/src-tauri/icons/icon.icns b/packages/desktop/src-tauri/icons/icon.icns index 932b0a9c..b6e17607 100644 Binary files a/packages/desktop/src-tauri/icons/icon.icns and b/packages/desktop/src-tauri/icons/icon.icns differ diff --git a/packages/desktop/src-tauri/icons/icon.png b/packages/desktop/src-tauri/icons/icon.png index 737b9f14..5e4087d0 100644 Binary files a/packages/desktop/src-tauri/icons/icon.png and b/packages/desktop/src-tauri/icons/icon.png differ diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 6cfcfe70..14dfb274 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -4,7 +4,7 @@ import { RiArrowDownLine } from '@remixicon/react'; import { ChatInput } from './ChatInput'; import { useSessionStore } from '@/stores/useSessionStore'; import { Skeleton } from '@/components/ui/skeleton'; -import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; +import ChatEmptyState from './ChatEmptyState'; import MessageList from './MessageList'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { useChatScrollManager } from '@/hooks/useChatScrollManager'; @@ -151,9 +151,7 @@ export const ChatContainer: React.FC = () => { className="flex flex-col h-full bg-background" style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} > -
- -
+ ); } @@ -165,7 +163,7 @@ export const ChatContainer: React.FC = () => { style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} >
- +
@@ -212,7 +210,7 @@ export const ChatContainer: React.FC = () => { style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} >
- +
diff --git a/packages/ui/src/components/chat/ChatEmptyState.tsx b/packages/ui/src/components/chat/ChatEmptyState.tsx index 0d01c2e8..68716f7e 100644 --- a/packages/ui/src/components/chat/ChatEmptyState.tsx +++ b/packages/ui/src/components/chat/ChatEmptyState.tsx @@ -1,11 +1,53 @@ import React from 'react'; import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; +import { TextLoop } from '@/components/ui/TextLoop'; +import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; + +const phrases = [ + "Fix the failing tests", + "Refactor this to be more readable", + "Add form validation", + "Optimize this function", + "Write tests for this", + "Explain how this works", + "Add a new feature", + "Help me debug this", + "Review my code", + "Simplify this logic", + "Add error handling", + "Create a new component", + "Update the documentation", + "Find the bug here", + "Improve performance", + "Add type definitions", +]; const ChatEmptyState: React.FC = () => { + const themeContext = useOptionalThemeSystem(); + + let isDark = true; + if (themeContext) { + isDark = themeContext.currentTheme.metadata.variant !== 'light'; + } else if (typeof window !== 'undefined') { + isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + } + + // Same colors as face fill in OpenChamberLogo, but higher opacity for text readability + const textColor = isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'; + return ( -
+
+ + {phrases.map((phrase) => ( + "{phrase}…" + ))} +
); }; diff --git a/packages/ui/src/components/ui/OpenChamberLogo.tsx b/packages/ui/src/components/ui/OpenChamberLogo.tsx index 675aed0b..0947c900 100644 --- a/packages/ui/src/components/ui/OpenChamberLogo.tsx +++ b/packages/ui/src/components/ui/OpenChamberLogo.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; interface OpenChamberLogoProps { @@ -8,6 +8,61 @@ interface OpenChamberLogoProps { isAnimated?: boolean; } +// Generate grid cells for a face (4x4 grid) +// Returns array of parallelogram paths in isometric projection +const generateFaceGrid = ( + topLeft: { x: number; y: number }, + topRight: { x: number; y: number }, + bottomRight: { x: number; y: number }, + bottomLeft: { x: number; y: number }, + gridSize: number = 4 +) => { + const cells: Array<{ path: string; row: number; col: number }> = []; + + for (let row = 0; row < gridSize; row++) { + for (let col = 0; col < gridSize; col++) { + // Interpolate corners for this cell + const t1 = col / gridSize; + const t2 = (col + 1) / gridSize; + const s1 = row / gridSize; + const s2 = (row + 1) / gridSize; + + // Bilinear interpolation for each corner of the cell + const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + const bilinear = (tl: number, tr: number, br: number, bl: number, t: number, s: number) => { + const top = lerp(tl, tr, t); + const bottom = lerp(bl, br, t); + return lerp(top, bottom, s); + }; + + const p1 = { + x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t1, s1), + y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t1, s1), + }; + const p2 = { + x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t2, s1), + y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t2, s1), + }; + const p3 = { + x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t2, s2), + y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t2, s2), + }; + const p4 = { + x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t1, s2), + y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t1, s2), + }; + + cells.push({ + path: `M${p1.x} ${p1.y} L${p2.x} ${p2.y} L${p3.x} ${p3.y} L${p4.x} ${p4.y} Z`, + row, + col, + }); + } + } + + return cells; +}; + export const OpenChamberLogo: React.FC = ({ className = '', width = 70, @@ -23,67 +78,146 @@ export const OpenChamberLogo: React.FC = ({ isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; } - const fillColor = isDark ? 'white' : 'black'; - const gradientId = 'shimmer-gradient'; + const strokeColor = isDark ? 'white' : 'black'; + const fillColor = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'; + const logoFillColor = isDark ? 'white' : 'black'; + const cellHighlightColor = isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.4)'; + + + + // Isometric cube geometry (mathematically correct) + // For true isometric: horizontal edges at ±30° from horizontal + // cos(30°) ≈ 0.866, sin(30°) = 0.5 + // Cube edge length = 46, center at (50, 52) - larger cube, slightly lower + const edge = 48; + const cos30 = 0.866; + const sin30 = 0.5; + const centerY = 50; + + // Key points of the isometric cube + const top = { x: 50, y: centerY - edge }; // top vertex + const left = { x: 50 - edge * cos30, y: centerY - edge * sin30 }; // top-left + const right = { x: 50 + edge * cos30, y: centerY - edge * sin30 }; // top-right + const center = { x: 50, y: centerY }; // center (front vertex of top face) + const bottomLeft = { x: 50 - edge * cos30, y: centerY + edge * sin30 }; // bottom-left + const bottomRight = { x: 50 + edge * cos30, y: centerY + edge * sin30 }; // bottom-right + const bottom = { x: 50, y: centerY + edge }; // bottom vertex + + // Isometric transformation matrix for top face + // Maps a flat square to the isometric rhombus (top face) + // Center of top face rhombus: average of top, left, center, right vertices + // topFaceCenter.x = (top.x + left.x + center.x + right.x) / 4 = 50 + // topFaceCenter.y = (top.y + left.y + center.y + right.y) / 4 + const topFaceCenterY = (top.y + left.y + center.y + right.y) / 4; + const isoMatrix = `matrix(0.866, 0.5, -0.866, 0.5, 50, ${topFaceCenterY})`; + + // Generate grid cells for both faces + // Left face: center -> left -> bottomLeft -> bottom + const leftFaceCells = generateFaceGrid(left, center, bottom, bottomLeft); + + // Right face: center -> right -> bottomRight -> bottom + const rightFaceCells = generateFaceGrid(center, right, bottomRight, bottom); + + // Generate random opacity values for cells (stable per component instance) + const cellOpacities = useMemo(() => { + const opacities: number[] = []; + for (let i = 0; i < 32; i++) { // 16 cells per face * 2 faces + opacities.push(0.1 + Math.random() * 0.5); // Random opacity 0.1-0.6 + } + return opacities; + }, []); return ( - {isAnimated && ( - - - - - - - - - - - - )} + {/* Left face - base fill */} + + {/* Left face - grid cells with varying opacity */} + {leftFaceCells.map((cell, i) => ( + + ))} + + {/* Right face - base fill */} + + {/* Right face - grid cells with varying opacity */} + {rightFaceCells.map((cell, i) => ( + + ))} + + {/* Top face - open (no fill), only stroke */} + + + {/* OpenCode logo on top face */} + + {isAnimated && ( + + )} + {/* + Isometric transform for top face: + OpenCode logo (32x40 viewBox) centered and projected to isometric plane + */} + + {/* OpenCode logo - outer frame with inner square */} + {/* Outer frame (centered at origin, original: 0,0 to 32,40) */} + + {/* Inner square */} + + + ); }; diff --git a/packages/ui/src/components/ui/TextLoop.tsx b/packages/ui/src/components/ui/TextLoop.tsx new file mode 100644 index 00000000..daaca4a9 --- /dev/null +++ b/packages/ui/src/components/ui/TextLoop.tsx @@ -0,0 +1,83 @@ +import { cn } from '@/lib/utils'; +import { + motion, + AnimatePresence, +} from 'motion/react'; +import type { + Transition, + Variants, + AnimatePresenceProps, +} from 'motion/react'; +import { useState, useEffect, Children } from 'react'; + +export type TextLoopProps = { + children: React.ReactNode[]; + className?: string; + interval?: number; + transition?: Transition; + variants?: Variants; + onIndexChange?: (index: number) => void; + trigger?: boolean; + mode?: AnimatePresenceProps['mode']; +}; + +export function TextLoop({ + children, + className, + interval = 2, + transition = { duration: 0.3 }, + variants, + onIndexChange, + trigger = true, + mode = 'popLayout', +}: TextLoopProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const items = Children.toArray(children); + + useEffect(() => { + if (!trigger) return; + + const intervalMs = interval * 1000; + const timer = setInterval(() => { + setCurrentIndex((current) => { + const next = (current + 1) % items.length; + onIndexChange?.(next); + return next; + }); + }, intervalMs); + return () => clearInterval(timer); + }, [items.length, interval, onIndexChange, trigger]); + + const motionVariants: Variants = { + initial: { y: 20, opacity: 0 }, + animate: { y: 0, opacity: 1 }, + exit: { y: -20, opacity: 0 }, + }; + + return ( +
+ {/* Invisible element to maintain consistent width based on longest item */} +
+ {items.map((item, i) => ( +
{item}
+ ))} +
+ {/* Animated visible element */} +
+ + + {items[currentIndex]} + + +
+
+ ); +} diff --git a/packages/vscode/assets/app-icon.png b/packages/vscode/assets/app-icon.png index c084f0b8..8d875b88 100644 Binary files a/packages/vscode/assets/app-icon.png and b/packages/vscode/assets/app-icon.png differ diff --git a/packages/vscode/assets/icon.svg b/packages/vscode/assets/icon.svg index 9ecacebf..eddd05c4 100644 --- a/packages/vscode/assets/icon.svg +++ b/packages/vscode/assets/icon.svg @@ -1,9 +1,60 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/vscode/webview/App.tsx b/packages/vscode/webview/App.tsx index 109b6bc9..b4ed10f8 100644 --- a/packages/vscode/webview/App.tsx +++ b/packages/vscode/webview/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useChatStore } from './stores/chatStore'; import { useNavigation } from './hooks/useNavigation'; +import { OpenChamberLogo } from './components/OpenChamberLogo'; type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; @@ -11,9 +12,18 @@ function ConnectionStatusBanner({ status, error, onRetry }: { }) { if (status === 'connected') return null; + // Show full-screen logo for connecting state + if (status === 'connecting') { + return ( +
+ +
+ ); + } + const messages: Record = { disconnected: 'Not connected to OpenCode API', - connecting: 'Connecting...', + connecting: '', connected: '', error: error || 'Connection error', }; @@ -22,9 +32,6 @@ function ConnectionStatusBanner({ status, error, onRetry }: {
- {status === 'connecting' && ( - - )} {messages[status]} {(status === 'disconnected' || status === 'error') && (