feat(ui): Raycast-inspired visual refresh with System Pulse and X-Ray footers (#57)

* feat(ui): update design tokens — system-ui font, warmer palette, accent triad

- Remove Merriweather serif font, switch to system-ui font stack
- Warmer dark mode neutrals (#161618 bg instead of #09090b)
- Add amber/purple accent tokens for signature color triad
- Update borders to rgba for softer feel
- Tighter line-height (1.5) and letter-spacing (-0.01em)
- Update PWA theme_color to match new palette

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(ui): frosted glass sidebar with backdrop-blur

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(ui): initialize shadcn/ui with button, dialog, input, select, tooltip, sonner

Set up shadcn/ui (v4) for Tailwind CSS v4 with CSS-first config. Added
path aliases (@/) to tsconfig.json and vite.config.ts. Installed six
components (button, dialog, input, select, tooltip, sonner) and wired
Sonner's <Toaster> into App.tsx for toast notifications. Removed the
next-themes dependency since this is a Vite/React project, not Next.js.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ui): disable create agent when unavailable, replace errors with toasts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(ui): add System Pulse bar and health check banner

- 3px gradient bar at top reflecting system state (idle/inferencing/agent)
- Health check banner when backend is unreachable
- Pulse colors: blue (inference), purple (agents), dim blue (idle)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(ui): add X-Ray message footers with collapsible telemetry

- MessageTelemetry type on ChatMessage (engine, model, speed, TTFT, timing)
- Capture timing data during SSE streaming in InputArea
- XRayFooter component: collapsed one-liner + expandable trace grid
- Integrated into MessageBubble, replacing old token count display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ui): resolve shadcn/ui CSS token conflicts with design system

- Namespace shadcn @theme inline variables (--color-cn-*) to avoid
  overriding project's --color-border, --color-accent, --radius-* tokens
- Remove @apply border-border, bg-background, text-foreground, font-sans
  that conflicted with our explicit CSS custom properties
- Remove dead Geist font import (we use system-ui)
- Remove ---break--- comment artifacts from shadcn CLI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-03-14 13:38:07 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 0dac51c649
commit e240fde74a
24 changed files with 4359 additions and 163 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+1 -4
View File
@@ -3,13 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#09090b" />
<meta name="theme-color" content="#161618" />
<meta name="description" content="OpenJarvis — on-device AI assistant" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700&display=swap" rel="stylesheet" />
<title>OpenJarvis</title>
</head>
<body>
+3287 -80
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -11,6 +11,8 @@
"tauri": "tauri"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2",
@@ -19,6 +21,8 @@
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-shell": "^2",
"@tauri-apps/plugin-updater": "^2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"katex": "^0.16.38",
"lucide-react": "^0.576.0",
"react": "^19.0.0",
@@ -30,7 +34,11 @@
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shadcn": "^4.0.7",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.11"
},
"devDependencies": {
+2
View File
@@ -9,6 +9,7 @@ import { AgentsPage } from './pages/AgentsPage';
import { LogsPage } from './pages/LogsPage';
import { CommandPalette } from './components/CommandPalette';
import { SetupScreen } from './components/SetupScreen';
import { Toaster } from './components/ui/sonner';
import { useAppStore } from './lib/store';
import { fetchModels, fetchServerInfo, fetchSavings, submitSavings, isTauri } from './lib/api';
import { OptInModal } from './components/OptInModal';
@@ -138,6 +139,7 @@ export default function App() {
<Route path="logs" element={<LogsPage />} />
</Route>
</Routes>
<Toaster position="bottom-right" />
{commandPaletteOpen && <CommandPalette />}
{optInModalOpen && (
<OptInModal onClose={() => setOptInModalOpen(false)} />
+15 -1
View File
@@ -5,7 +5,7 @@ import { streamChat } from '../../lib/sse';
import { fetchSavings } from '../../lib/api';
import { MicButton } from './MicButton';
import { useSpeech } from '../../hooks/useSpeech';
import type { ChatMessage, ToolCallInfo, TokenUsage } from '../../types';
import type { ChatMessage, ToolCallInfo, TokenUsage, MessageTelemetry } from '../../types';
export function InputArea() {
const [input, setInput] = useState('');
@@ -129,6 +129,7 @@ export function InputArea() {
let usage: TokenUsage | undefined;
const toolCalls: ToolCallInfo[] = [];
let lastFlush = 0;
let ttftMs: number | undefined;
setStreamState({
isStreaming: true,
@@ -202,6 +203,7 @@ export function InputArea() {
const delta = data.choices?.[0]?.delta;
if (data.usage) usage = data.usage;
if (delta?.content) {
if (!ttftMs) ttftMs = Date.now() - startTime;
accumulatedContent += delta.content;
setStreamState({ content: accumulatedContent, phase: '' });
@@ -236,11 +238,23 @@ export function InputArea() {
if (!accumulatedContent) {
accumulatedContent = 'No response was generated. Please try again.';
}
const totalMs = Date.now() - startTime;
const serverInfo = useAppStore.getState().serverInfo;
const telemetry: MessageTelemetry = {
engine: serverInfo?.engine,
model_id: selectedModel,
total_ms: totalMs,
ttft_ms: ttftMs,
tokens_per_sec: usage?.completion_tokens
? usage.completion_tokens / (totalMs / 1000)
: undefined,
};
updateLastAssistant(
convId,
accumulatedContent,
toolCalls.length > 0 ? toolCalls : undefined,
usage,
telemetry,
);
if (timerRef.current) {
clearInterval(timerRef.current);
@@ -7,6 +7,7 @@ import remarkMath from 'remark-math';
import 'katex/dist/katex.min.css';
import { Copy, Check } from 'lucide-react';
import { ToolCallCard } from './ToolCallCard';
import { XRayFooter } from './XRayFooter';
import type { ChatMessage } from '../../types';
function stripThinkTags(text: string): string {
@@ -145,15 +146,11 @@ export function MessageBubble({ message }: Props) {
</div>
)}
{/* Footer: usage + copy */}
<div className="flex items-center gap-2 mt-1.5 min-h-[24px]">
{/* Footer: copy + x-ray */}
<div className="flex items-center gap-2 mt-1.5">
<CopyMessageButton content={cleanContent} />
{message.usage && (
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
{message.usage.total_tokens} tokens
</span>
)}
</div>
<XRayFooter usage={message.usage} telemetry={message.telemetry} />
</div>
);
}
@@ -0,0 +1,98 @@
import { useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import type { TokenUsage, MessageTelemetry } from '../../types';
interface Props {
usage?: TokenUsage;
telemetry?: MessageTelemetry;
}
function formatMs(ms: number): string {
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
}
export function XRayFooter({ usage, telemetry }: Props) {
const [expanded, setExpanded] = useState(false);
// Build collapsed summary parts
const parts: string[] = [];
if (telemetry?.engine) parts.push(telemetry.engine);
if (telemetry?.model_id) parts.push(telemetry.model_id);
if (usage?.completion_tokens) parts.push(`${usage.completion_tokens} tok`);
if (telemetry?.total_ms) parts.push(formatMs(telemetry.total_ms));
if (parts.length === 0 && !usage?.total_tokens) return null;
// Fallback: just show total tokens if no telemetry
const summary = parts.length > 0 ? parts.join(' \u00B7 ') : `${usage!.total_tokens} tokens`;
// Build expanded rows
const rows: Array<{ label: string; value: string; color?: string }> = [];
if (telemetry?.engine) {
const modelDetail = telemetry.model_id || '';
rows.push({ label: 'Engine', value: `${telemetry.engine}${modelDetail ? ` (${modelDetail})` : ''}` });
}
if (usage) {
rows.push({ label: 'Tokens', value: `${usage.completion_tokens} generated \u00B7 ${usage.prompt_tokens} prompt` });
}
if (telemetry?.tokens_per_sec) {
rows.push({ label: 'Speed', value: `${Math.round(telemetry.tokens_per_sec)} tok/s` });
}
if (telemetry?.ttft_ms != null || telemetry?.total_ms != null) {
const latencyParts: string[] = [];
if (telemetry.ttft_ms != null) latencyParts.push(`TTFT ${formatMs(telemetry.ttft_ms)}`);
if (telemetry.total_ms != null) latencyParts.push(`Total ${formatMs(telemetry.total_ms)}`);
rows.push({ label: 'Latency', value: latencyParts.join(' \u00B7 ') });
}
return (
<div style={{ borderTop: '1px solid var(--color-border-subtle)', marginTop: '0.375rem' }}>
{/* Collapsed row */}
<button
onClick={() => rows.length > 0 && setExpanded(!expanded)}
className="flex items-center gap-2 w-full text-left py-1"
style={{ cursor: rows.length > 0 ? 'pointer' : 'default' }}
>
<span
className="w-1 h-1 rounded-full shrink-0"
style={{ background: 'var(--color-accent)' }}
/>
<span
className="text-[11px] flex-1"
style={{ color: 'var(--color-text-tertiary)', fontFamily: 'system-ui' }}
>
{summary}
</span>
{rows.length > 0 && (
expanded
? <ChevronUp size={10} style={{ color: 'var(--color-text-tertiary)' }} />
: <ChevronDown size={10} style={{ color: 'var(--color-text-tertiary)' }} />
)}
</button>
{/* Expanded trace */}
{expanded && rows.length > 0 && (
<div
className="rounded-lg mt-1 px-3 py-2"
style={{ background: 'rgba(0, 0, 0, 0.15)' }}
>
<div className="grid gap-y-0.5" style={{ gridTemplateColumns: 'auto 1fr', columnGap: '1rem' }}>
{rows.map((row) => (
<div key={row.label} className="contents">
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)', fontFamily: 'monospace' }}>
{row.label}
</span>
<span
className="text-[11px]"
style={{ color: row.color || 'var(--color-text-secondary)', fontFamily: 'monospace' }}
>
{row.value}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
+57 -11
View File
@@ -1,23 +1,69 @@
import { Outlet } from 'react-router';
import { useEffect, useState } from 'react';
import { Outlet, useNavigate } from 'react-router';
import { Sidebar } from './Sidebar/Sidebar';
import { SystemPulse } from './SystemPulse';
import { useAppStore } from '../lib/store';
import { checkHealth } from '../lib/api';
export function Layout() {
const sidebarOpen = useAppStore((s) => s.sidebarOpen);
const [apiReachable, setApiReachable] = useState<boolean | null>(null);
useEffect(() => {
const check = () => checkHealth().then(setApiReachable);
check();
const interval = setInterval(check, 30000);
const onFocus = () => check();
window.addEventListener('focus', onFocus);
return () => {
clearInterval(interval);
window.removeEventListener('focus', onFocus);
};
}, []);
const navigate = useNavigate();
return (
<div className="flex h-full w-full overflow-hidden">
<Sidebar />
{/* Overlay for mobile when sidebar is open */}
{sidebarOpen && (
<div className="flex flex-col h-full w-full overflow-hidden" style={{ paddingTop: '3px' }}>
<SystemPulse apiReachable={apiReachable} />
{/* Health check banner */}
{apiReachable === false && (
<div
className="fixed inset-0 z-20 bg-black/40 md:hidden"
onClick={() => useAppStore.getState().setSidebarOpen(false)}
/>
className="flex items-center gap-3 px-4 py-2 text-sm shrink-0"
style={{
background: 'rgba(239, 68, 68, 0.08)',
borderBottom: '1px solid rgba(239, 68, 68, 0.15)',
color: 'var(--color-text)',
}}
>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: 'var(--color-error)' }}
/>
<span>Cannot reach OpenJarvis backend</span>
<button
onClick={() => navigate('/settings')}
className="text-sm underline cursor-pointer ml-auto shrink-0"
style={{ color: 'var(--color-accent)' }}
>
Change URL
</button>
</div>
)}
<main className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--color-bg)' }}>
<Outlet />
</main>
<div className="flex flex-1 min-h-0">
<Sidebar />
{sidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/40 md:hidden"
onClick={() => useAppStore.getState().setSidebarOpen(false)}
/>
)}
<main className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--color-bg)' }}>
<Outlet />
</main>
</div>
</div>
);
}
+7 -2
View File
@@ -74,7 +74,12 @@ export function Sidebar() {
fixed md:relative z-30
${sidebarOpen ? 'w-[260px]' : 'w-0'}
`}
style={{ background: 'var(--color-sidebar)', borderRight: sidebarOpen ? '1px solid var(--color-border)' : 'none' }}
style={{
background: 'var(--color-sidebar)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderRight: sidebarOpen ? '1px solid var(--color-border)' : 'none',
}}
>
<div className="flex flex-col h-full w-[260px]">
{/* Header */}
@@ -182,7 +187,7 @@ export function Sidebar() {
onClick={() => navigate(item.path)}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors w-full text-left cursor-pointer"
style={{
background: isActive ? 'var(--color-bg-tertiary)' : 'transparent',
background: isActive ? 'var(--color-accent-subtle)' : 'transparent',
color: isActive ? 'var(--color-text)' : 'var(--color-text-secondary)',
fontWeight: isActive ? 500 : 400,
}}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { useAppStore } from '../lib/store';
import { fetchManagedAgents } from '../lib/api';
type PulseState = 'idle' | 'inferencing' | 'agent-active' | 'hidden';
const PULSE_CONFIG: Record<Exclude<PulseState, 'hidden'>, { color: string; animation: string }> = {
idle: {
color: 'rgba(59, 130, 246, 0.3)',
animation: 'none',
},
inferencing: {
color: '#3b82f6',
animation: 'pulse-glow 2s ease-in-out infinite',
},
'agent-active': {
color: '#8b5cf6',
animation: 'pulse-travel 3s linear infinite',
},
};
export function SystemPulse({ apiReachable }: { apiReachable: boolean | null }) {
const isStreaming = useAppStore((s) => s.streamState.isStreaming);
const [hasRunningAgent, setHasRunningAgent] = useState(false);
// Poll for running agents every 30s
useEffect(() => {
if (apiReachable === false) return;
const check = () =>
fetchManagedAgents()
.then((agents) => setHasRunningAgent(agents.some((a) => a.status === 'running')))
.catch(() => {});
check();
const interval = setInterval(check, 30000);
return () => clearInterval(interval);
}, [apiReachable]);
if (apiReachable === false) return null;
// Priority: agent-active > inferencing > idle
let state: PulseState = 'idle';
if (isStreaming) state = 'inferencing';
if (hasRunningAgent) state = 'agent-active';
const config = PULSE_CONFIG[state];
const isTravel = state === 'agent-active';
return (
<div
className="fixed top-0 left-0 right-0 h-[3px] z-50"
style={{
background: isTravel
? `linear-gradient(90deg, transparent, ${config.color}, transparent)`
: `linear-gradient(90deg, transparent 5%, ${config.color} 30%, ${config.color} 70%, transparent 95%)`,
backgroundSize: isTravel ? '200% 100%' : '100% 100%',
animation: config.animation,
}}
/>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+157
View File
@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+199
View File
@@ -0,0 +1,199 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+43
View File
@@ -0,0 +1,43 @@
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+64
View File
@@ -0,0 +1,64 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+180 -37
View File
@@ -1,4 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
/*
* OpenJarvis design tokens.
@@ -7,23 +10,31 @@
@layer base {
:root {
--color-bg: #ffffff;
--color-bg: #f9f9f9;
--color-bg-secondary: #f4f4f5;
--color-bg-tertiary: #e4e4e7;
--color-surface: #ffffff;
--color-sidebar: #fafafa;
--color-border: #e4e4e7;
--color-border-subtle: #f4f4f5;
--color-sidebar: rgba(250, 250, 250, 0.85);
--color-border: rgba(0, 0, 0, 0.08);
--color-border-subtle: rgba(0, 0, 0, 0.04);
--color-text: #09090b;
--color-text-secondary: #71717a;
--color-text-tertiary: #a1a1aa;
--color-text-inverse: #fafafa;
--color-accent: #2563eb;
--color-accent-hover: #1d4ed8;
--color-accent: #3b82f6;
--color-accent-hover: #2563eb;
--color-accent-subtle: #eff6ff;
--color-accent-amber: #f59e0b;
--color-accent-amber-hover: #d97706;
--color-accent-amber-subtle: rgba(245, 158, 11, 0.1);
--color-accent-purple: #8b5cf6;
--color-accent-purple-hover: #7c3aed;
--color-accent-purple-subtle: rgba(139, 92, 246, 0.1);
--color-accent-blue-subtle: rgba(59, 130, 246, 0.1);
--color-success: #16a34a;
--color-warning: #ca8a04;
--color-error: #dc2626;
@@ -32,7 +43,7 @@
--color-input-bg: #ffffff;
--color-input-border: #d4d4d8;
--color-user-bubble: #2563eb;
--color-user-bubble: #3b82f6;
--color-user-bubble-text: #ffffff;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
@@ -52,30 +63,38 @@
}
.dark {
--color-bg: #09090b;
--color-bg-secondary: #18181b;
--color-bg-tertiary: #27272a;
--color-surface: #18181b;
--color-sidebar: #0f0f12;
--color-border: #27272a;
--color-border-subtle: #1e1e22;
--color-bg: #161618;
--color-bg-secondary: #1e1e21;
--color-bg-tertiary: #2a2a2d;
--color-surface: #1e1e21;
--color-sidebar: rgba(30, 30, 33, 0.85);
--color-border: rgba(255, 255, 255, 0.08);
--color-border-subtle: rgba(255, 255, 255, 0.04);
--color-text: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-tertiary: #71717a;
--color-text-inverse: #09090b;
--color-text: #e8e8ed;
--color-text-secondary: #8e8e93;
--color-text-tertiary: #636366;
--color-text-inverse: #161618;
--color-accent: #3b82f6;
--color-accent-hover: #60a5fa;
--color-accent-subtle: #172554;
--color-accent-amber: #f59e0b;
--color-accent-amber-hover: #fbbf24;
--color-accent-amber-subtle: rgba(245, 158, 11, 0.15);
--color-accent-purple: #8b5cf6;
--color-accent-purple-hover: #a78bfa;
--color-accent-purple-subtle: rgba(139, 92, 246, 0.15);
--color-accent-blue-subtle: rgba(59, 130, 246, 0.15);
--color-success: #22c55e;
--color-warning: #eab308;
--color-error: #ef4444;
--color-code-bg: #1e1e22;
--color-input-bg: #18181b;
--color-input-border: #3f3f46;
--color-input-bg: #1e1e21;
--color-input-border: rgba(255, 255, 255, 0.1);
--color-user-bubble: #3b82f6;
--color-user-bubble-text: #ffffff;
@@ -89,30 +108,38 @@
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--color-bg: #09090b;
--color-bg-secondary: #18181b;
--color-bg-tertiary: #27272a;
--color-surface: #18181b;
--color-sidebar: #0f0f12;
--color-border: #27272a;
--color-border-subtle: #1e1e22;
--color-bg: #161618;
--color-bg-secondary: #1e1e21;
--color-bg-tertiary: #2a2a2d;
--color-surface: #1e1e21;
--color-sidebar: rgba(30, 30, 33, 0.85);
--color-border: rgba(255, 255, 255, 0.08);
--color-border-subtle: rgba(255, 255, 255, 0.04);
--color-text: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-tertiary: #71717a;
--color-text-inverse: #09090b;
--color-text: #e8e8ed;
--color-text-secondary: #8e8e93;
--color-text-tertiary: #636366;
--color-text-inverse: #161618;
--color-accent: #3b82f6;
--color-accent-hover: #60a5fa;
--color-accent-subtle: #172554;
--color-accent-amber: #f59e0b;
--color-accent-amber-hover: #fbbf24;
--color-accent-amber-subtle: rgba(245, 158, 11, 0.15);
--color-accent-purple: #8b5cf6;
--color-accent-purple-hover: #a78bfa;
--color-accent-purple-subtle: rgba(139, 92, 246, 0.15);
--color-accent-blue-subtle: rgba(59, 130, 246, 0.15);
--color-success: #22c55e;
--color-warning: #eab308;
--color-error: #ef4444;
--color-code-bg: #1e1e22;
--color-input-bg: #18181b;
--color-input-border: #3f3f46;
--color-input-bg: #1e1e21;
--color-input-border: rgba(255, 255, 255, 0.1);
--color-user-bubble: #3b82f6;
--color-user-bubble-text: #ffffff;
@@ -138,9 +165,10 @@
}
body {
font-family: "Merriweather", Georgia, "Times New Roman", serif;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-weight: 400;
line-height: 1.7;
line-height: 1.5;
letter-spacing: -0.01em;
background-color: var(--color-bg);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
@@ -241,8 +269,8 @@
/* Markdown prose styling */
.prose {
line-height: 1.75;
font-size: 0.9375rem;
line-height: 1.55;
font-size: 0.875rem;
}
.prose p {
@@ -298,6 +326,7 @@
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
letter-spacing: -0.01em;
}
.prose h1 { font-size: 1.375em; }
@@ -337,4 +366,118 @@
border-top: 1px solid var(--color-border);
margin: 1em 0;
}
/* System Pulse animations */
@keyframes pulse-glow {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
@keyframes pulse-travel {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* shadcn component tokens — namespaced to avoid collision with OpenJarvis design tokens */
--color-cn-sidebar-ring: var(--sidebar-ring);
--color-cn-sidebar-border: var(--sidebar-border);
--color-cn-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-cn-sidebar-accent: var(--sidebar-accent);
--color-cn-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-cn-sidebar-primary: var(--sidebar-primary);
--color-cn-sidebar-foreground: var(--sidebar-foreground);
--color-cn-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-cn-input: var(--input);
--color-cn-border: var(--border);
--color-destructive: var(--destructive);
--color-cn-accent-foreground: var(--accent-foreground);
--color-cn-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
/* Keep project radius tokens — don't override with shadcn calc values */
}
+4
View File
@@ -4,6 +4,7 @@ import type {
ChatMessage,
LogEntry,
ModelInfo,
MessageTelemetry,
SavingsData,
ServerInfo,
StreamState,
@@ -147,6 +148,7 @@ interface AppState {
content: string,
toolCalls?: ToolCallInfo[],
usage?: TokenUsage,
telemetry?: MessageTelemetry,
) => void;
setStreamState: (state: Partial<StreamState>) => void;
resetStream: () => void;
@@ -334,6 +336,7 @@ export const useAppStore = create<AppState>((set, get) => {
content: string,
toolCalls?: ToolCallInfo[],
usage?: TokenUsage,
telemetry?: MessageTelemetry,
) => {
const store = loadConversations();
const conv = store.conversations[conversationId];
@@ -343,6 +346,7 @@ export const useAppStore = create<AppState>((set, get) => {
lastMsg.content = content;
if (toolCalls) lastMsg.toolCalls = toolCalls;
if (usage) lastMsg.usage = usage;
if (telemetry) lastMsg.telemetry = telemetry;
conv.updatedAt = Date.now();
saveConversations(store);
set({ messages: [...conv.messages] });
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+42 -18
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import { useAppStore } from '../lib/store';
import {
fetchManagedAgents,
@@ -161,7 +162,6 @@ function LaunchWizard({
learningEnabled: false,
});
const [launching, setLaunching] = useState(false);
const [error, setError] = useState('');
function update(partial: Partial<WizardState>) {
setWizard((prev) => ({ ...prev, ...partial }));
@@ -184,11 +184,10 @@ function LaunchWizard({
async function handleLaunch() {
if (!wizard.name.trim()) {
setError('Agent name is required.');
toast.error('Agent name is required');
return;
}
setLaunching(true);
setError('');
try {
const config: Record<string, unknown> = {
schedule_type: wizard.scheduleType,
@@ -202,9 +201,12 @@ function LaunchWizard({
template_id: wizard.templateId || undefined,
config,
});
toast.success(`Agent "${wizard.name}" launched`);
onLaunched();
} catch {
setError('Failed to create agent. Please try again.');
} catch (err) {
toast.error('Could not create agent', {
description: 'Agent manager endpoint not available. Check that agent_manager.enabled = true in your config.',
});
} finally {
setLaunching(false);
}
@@ -466,11 +468,6 @@ function LaunchWizard({
<span style={{ color: 'var(--color-text)' }}>{wizard.learningEnabled ? 'Enabled' : 'Disabled'}</span>
</div>
</div>
{error && (
<p className="text-sm" style={{ color: '#ef4444' }}>
{error}
</p>
)}
</div>
)}
</div>
@@ -982,6 +979,7 @@ export function AgentsPage() {
const selectedAgentId = useAppStore((s) => s.selectedAgentId);
const setSelectedAgentId = useAppStore((s) => s.setSelectedAgentId);
const [loading, setLoading] = useState(true);
const [agentManagerAvailable, setAgentManagerAvailable] = useState<boolean | null>(null);
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [channels, setChannels] = useState<ChannelBinding[]>([]);
const [templates, setTemplates] = useState<AgentTemplate[]>([]);
@@ -992,8 +990,12 @@ export function AgentsPage() {
try {
const agents = await fetchManagedAgents();
setManagedAgents(agents);
} catch {
// Server may be down
setAgentManagerAvailable(true);
} catch (err: any) {
if (err.message?.includes('404')) {
setAgentManagerAvailable(false);
}
setManagedAgents([]);
} finally {
setLoading(false);
}
@@ -1326,14 +1328,32 @@ export function AgentsPage() {
Agents
</h1>
<button
onClick={() => setShowWizard(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium cursor-pointer transition-colors"
style={{ background: 'var(--color-accent)', color: '#fff' }}
onClick={() => agentManagerAvailable && setShowWizard(true)}
disabled={agentManagerAvailable === false}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: agentManagerAvailable === false ? 'var(--color-bg-tertiary)' : 'var(--color-accent)',
color: agentManagerAvailable === false ? 'var(--color-text-tertiary)' : '#fff',
}}
>
<Plus size={15} /> New Agent
</button>
</div>
{agentManagerAvailable === false && (
<div
className="mx-4 mt-2 px-4 py-3 rounded-lg flex items-center gap-3 text-sm"
style={{
background: 'var(--color-accent-amber-subtle)',
border: '1px solid rgba(245, 158, 11, 0.2)',
color: 'var(--color-accent-amber)',
}}
>
<AlertTriangle size={16} />
<span>Agent manager is not enabled. Set <code className="font-mono text-xs">agent_manager.enabled = true</code> in your config.</span>
</div>
)}
{/* Agent cards grid */}
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
{managedAgents.map((a) => (
@@ -1361,9 +1381,13 @@ export function AgentsPage() {
</p>
<p className="text-sm mb-6">Create your first agent to get started with autonomous task management.</p>
<button
onClick={() => setShowWizard(true)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium cursor-pointer"
style={{ background: 'var(--color-accent)', color: '#fff' }}
onClick={() => agentManagerAvailable && setShowWizard(true)}
disabled={agentManagerAvailable === false}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: agentManagerAvailable === false ? 'var(--color-bg-tertiary)' : 'var(--color-accent)',
color: agentManagerAvailable === false ? 'var(--color-text-tertiary)' : '#fff',
}}
>
<Plus size={15} /> Launch your first agent
</button>
+9
View File
@@ -50,6 +50,14 @@ export interface TokenUsage {
total_tokens: number;
}
export interface MessageTelemetry {
engine?: string;
model_id?: string;
tokens_per_sec?: number;
ttft_ms?: number;
total_ms?: number;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
@@ -57,6 +65,7 @@ export interface ChatMessage {
timestamp: number;
toolCalls?: ToolCallInfo[];
usage?: TokenUsage;
telemetry?: MessageTelemetry;
}
export interface Conversation {
+5 -1
View File
@@ -15,7 +15,11 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+8 -2
View File
@@ -1,9 +1,15 @@
import path from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [
react(),
tailwindcss(),
@@ -13,8 +19,8 @@ export default defineConfig({
name: 'OpenJarvis',
short_name: 'Jarvis',
description: 'On-device AI assistant',
theme_color: '#09090b',
background_color: '#09090b',
theme_color: '#161618',
background_color: '#161618',
display: 'standalone',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },