diff --git a/packages/web-ui/example/src/components/session-sidebar.ts b/packages/web-ui/example/src/components/session-sidebar.ts new file mode 100644 index 0000000..0c8913a --- /dev/null +++ b/packages/web-ui/example/src/components/session-sidebar.ts @@ -0,0 +1,131 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import type { SessionMetadata } from "@jaeswift/jae-web-ui"; + +@customElement("jae-session-sidebar") +export class JaeSessionSidebar extends LitElement { + @property({ type: Boolean }) collapsed = false; + @property({ type: String }) currentSessionId: string | undefined = undefined; + @property({ attribute: false }) onLoadSession?: (id: string) => void; + @property({ attribute: false }) onNewSession?: () => void; + + @state() private _sessions: SessionMetadata[] = []; + @state() private _pinnedIds: Set = new Set(); + @state() private _confirmDelete: string | null = null; + + protected override createRenderRoot() { return this; } + + override connectedCallback() { + super.connectedCallback(); + const raw = localStorage.getItem("jae-pinned-sessions"); + if (raw) { try { this._pinnedIds = new Set(JSON.parse(raw)); } catch {} } + } + + setSessions(sessions: SessionMetadata[]) { + this._sessions = [...sessions]; + this.requestUpdate(); + } + + private _togglePin(e: Event, id: string) { + e.stopPropagation(); + const s = new Set(this._pinnedIds); + s.has(id) ? s.delete(id) : s.add(id); + this._pinnedIds = s; + localStorage.setItem("jae-pinned-sessions", JSON.stringify([...s])); + this.requestUpdate(); + } + + private _deleteSession(e: Event, id: string) { + e.stopPropagation(); + if (this._confirmDelete === id) { + this._confirmDelete = null; + this.dispatchEvent(new CustomEvent("delete-session", { detail: id, bubbles: true, composed: true })); + } else { + this._confirmDelete = id; + this.requestUpdate(); + setTimeout(() => { this._confirmDelete = null; this.requestUpdate(); }, 3000); + } + } + + private _fmt(iso: string) { + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 60000) return "just now"; + if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"; + if (ms < 86400000) return Math.floor(ms / 3600000) + "h ago"; + if (ms < 604800000) return Math.floor(ms / 86400000) + "d ago"; + return new Date(iso).toLocaleDateString(); + } + + override render() { + if (this.collapsed) return html``; + const pinned = this._sessions.filter(s => this._pinnedIds.has(s.id)) + .sort((a, b) => b.lastModified.localeCompare(a.lastModified)); + const rest = this._sessions.filter(s => !this._pinnedIds.has(s.id)) + .sort((a, b) => b.lastModified.localeCompare(a.lastModified)); + const sorted = [...pinned, ...rest]; + + return html` +
+
+ Chats + +
+
+ ${sorted.length === 0 ? html` +
+
💬
+
No chats yet
+
+ ` : sorted.map(s => html` +
this.onLoadSession?.(s.id)}> + ${this._pinnedIds.has(s.id) ? html` +
+ ` : html``} +
+
${s.title || "Untitled"}
+
${this._fmt(s.lastModified)}
+
+
+ + +
+
+ `)} +
+
+
+ ${sorted.length} chat${sorted.length !== 1 ? "s" : ""} +
+
+
+ `; + } +} diff --git a/packages/web-ui/example/src/components/utility-toggle.ts b/packages/web-ui/example/src/components/utility-toggle.ts index 809f363..bf50499 100644 --- a/packages/web-ui/example/src/components/utility-toggle.ts +++ b/packages/web-ui/example/src/components/utility-toggle.ts @@ -58,7 +58,7 @@ export class JaeUtilityToggle extends LitElement { ${this.open ? html` -
+
JAE diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index e5e0e94..de7dc21 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -1,4 +1,3 @@ - import "@mariozechner/mini-lit/dist/ThemeToggle.js"; import { Agent, type AgentMessage } from "@jaeswift/jae-agent-core"; import { getModel } from "@jaeswift/jae-ai"; @@ -20,12 +19,12 @@ import { setAppStorage, } from "@jaeswift/jae-web-ui"; import { html, render } from "lit"; -import { Bell, Brain, Download, History, Keyboard, Plus, Settings, Terminal } from "lucide"; +import { Brain, Download, History, Keyboard, Plus, Settings } from "lucide"; import "./app.css"; import { icon } from "@mariozechner/mini-lit"; import { Button } from "@mariozechner/mini-lit/dist/Button.js"; import { Input } from "@mariozechner/mini-lit/dist/Input.js"; -import { createSystemNotification, customConvertToLlm, registerCustomMessageRenderers } from "./custom-messages.js"; +import { customConvertToLlm, registerCustomMessageRenderers } from "./custom-messages.js"; import { createWebSearchTool, createImageGenTool, createTTSTool } from "@jaeswift/jae-web-ui"; import { CommandPalette } from "./components/command-palette.js"; import { KeyboardShortcuts } from "./components/keyboard-shortcuts.js"; @@ -40,11 +39,11 @@ import { JaeEmptyState } from "./components/empty-state.js"; import { JaeUtilityToggle, type UtilityVisibility } from "./components/utility-toggle.js"; import "./components/empty-state.js"; import "./components/utility-toggle.js"; +import { JaeSessionSidebar } from "./components/session-sidebar.js"; +import "./components/session-sidebar.js"; -// Register custom message renderers registerCustomMessageRenderers(); -// Create stores const settings = new SettingsStore(); const providerKeys = new ProviderKeysStore(); const sessions = new SessionsStore(); @@ -79,11 +78,23 @@ let agent: Agent; let chatPanel: ChatPanel; let agentUnsubscribe: (() => void) | undefined; -// --- Feature instances --- const commandPalette = document.createElement("command-palette") as CommandPalette; const keyboardShortcuts = document.createElement("keyboard-shortcuts") as KeyboardShortcuts; const memoryManager = document.createElement("memory-manager") as MemoryManager; const costTracker = document.createElement("cost-tracker") as CostTracker; +const sidebar = document.createElement("jae-session-sidebar") as JaeSessionSidebar; + +sidebar.onLoadSession = async (id: string) => { await loadSession(id); }; +sidebar.onNewSession = () => newSession(); +sidebar.addEventListener("delete-session", async (e: Event) => { + const id = (e as CustomEvent).detail; + if (storage.sessions) { + await storage.sessions.delete(id); + if (id === currentSessionId) newSession(); + await refreshSidebar(); + } +}); + const utilityToggle = document.createElement("jae-utility-toggle") as JaeUtilityToggle; utilityToggle.addEventListener("visibility-change", (e: Event) => { const vis = (e as CustomEvent).detail; @@ -95,11 +106,19 @@ utilityToggle.addEventListener("visibility-change", (e: Event) => { chatEl.classList.toggle("hide-timestamps", !vis.showTimestamps); } }); + document.body.appendChild(commandPalette); document.body.appendChild(keyboardShortcuts); document.body.appendChild(memoryManager); -// --- Global keyboard handler --- +const refreshSidebar = async () => { + if (storage.sessions) { + const all = await storage.sessions.getAllMetadata(); + sidebar.setSessions(all); + sidebar.currentSessionId = currentSessionId; + } +}; + window.addEventListener("keydown", (e: KeyboardEvent) => { const meta = e.metaKey || e.ctrlKey; if (meta && e.key === "k") { e.preventDefault(); commandPalette.show(); return; } @@ -110,49 +129,16 @@ window.addEventListener("keydown", (e: KeyboardEvent) => { } }); -// --- Setup command palette commands --- function setupCommands() { commandPalette.setCommands([ - { - id: "new-session", label: "New Session", description: "Start a fresh conversation", - shortcut: "Ctrl+N", keywords: ["new", "fresh", "start"], - action: newSession, - }, - { - id: "sessions", label: "Session History", description: "Browse and load past sessions", - shortcut: "Ctrl+H", keywords: ["history", "sessions", "past"], - action: () => SessionListDialog.open(async (id) => await loadSession(id), (id) => { if (id === currentSessionId) newSession(); }), - }, - { - id: "export-md", label: "Export as Markdown", description: "Download current session as .md", - shortcut: "Ctrl+E", keywords: ["export", "download", "save", "markdown"], - action: () => handleExport("markdown"), - }, - { - id: "export-json", label: "Export as JSON", description: "Download current session as .json", - keywords: ["export", "download", "save", "json"], - action: () => handleExport("json"), - }, - { - id: "memory", label: "Memory Manager", description: "Browse and manage stored memories", - keywords: ["memory", "remember", "recall", "brain"], - action: () => memoryManager.show(), - }, - { - id: "settings", label: "Settings", description: "Configure providers and models", - keywords: ["settings", "config", "provider", "api", "key", "model"], - action: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]), - }, - { - id: "shortcuts", label: "Keyboard Shortcuts", description: "View all keyboard shortcuts", - shortcut: "?", keywords: ["keyboard", "shortcuts", "help", "keys"], - action: () => keyboardShortcuts.show(), - }, - { - id: "cost", label: "Token Usage & Cost", description: "View API usage stats for this session", - keywords: ["tokens", "cost", "usage", "spend"], - action: () => costTracker.dispatchEvent(new MouseEvent("click")), - }, + { id: "new-session", label: "New Session", description: "Start a fresh conversation", shortcut: "Ctrl+N", keywords: ["new", "fresh", "start"], action: newSession }, + { id: "sessions", label: "Session History", description: "Browse and load past sessions", shortcut: "Ctrl+H", keywords: ["history", "sessions", "past"], action: () => SessionListDialog.open(async (id) => await loadSession(id), (id) => { if (id === currentSessionId) newSession(); }) }, + { id: "export-md", label: "Export as Markdown", description: "Download current session as .md", shortcut: "Ctrl+E", keywords: ["export", "download", "markdown"], action: () => handleExport("markdown") }, + { id: "export-json", label: "Export as JSON", description: "Download current session as .json", keywords: ["export", "download", "json"], action: () => handleExport("json") }, + { id: "memory", label: "Memory Manager", description: "Browse and manage stored memories", keywords: ["memory", "remember", "recall"], action: () => memoryManager.show() }, + { id: "settings", label: "Settings", description: "Configure providers and models", keywords: ["settings", "config", "provider", "api", "model"], action: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]) }, + { id: "shortcuts", label: "Keyboard Shortcuts", description: "View all keyboard shortcuts", shortcut: "?", keywords: ["keyboard", "shortcuts", "help"], action: () => keyboardShortcuts.show() }, + { id: "cost", label: "Token Usage & Cost", description: "View API usage stats for this session", keywords: ["tokens", "cost", "usage"], action: () => costTracker.dispatchEvent(new MouseEvent("click")) }, ]); } @@ -175,7 +161,7 @@ const generateTitle = (messages: AgentMessage[]): string => { if (!text) return ""; const sentenceEnd = text.search(/[.!?]/); if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1); - return text.length <= 50 ? text : `${text.substring(0, 47)}...`; + return text.length <= 50 ? text : text.substring(0, 47) + "..."; }; const shouldSaveSession = (messages: AgentMessage[]): boolean => { @@ -203,6 +189,7 @@ const saveSession = async () => { preview: generateTitle(state.messages), }; await storage.sessions.save(sessionData, metadata); + await refreshSidebar(); } catch (err) { console.error("Failed to save session:", err); } }; @@ -214,33 +201,22 @@ const updateUrl = (sessionId: string) => { const createAgent = async (initialState?: Partial) => { if (agentUnsubscribe) agentUnsubscribe(); - agent = new Agent({ initialState: initialState || { - systemPrompt: `You are a helpful AI assistant with access to various tools. - -Available tools: -- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment -- Web Search: Search the web via DuckDuckGo for current information -- Image Generation: Generate images using Venice AI -- Text to Speech: Convert text to audio -- Memory: Save and recall information across sessions -- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts - -Feel free to use these tools when needed to provide accurate and helpful responses.`, - model: getModel("anthropic", "claude-sonnet-4-5-20250929"), + systemPrompt: "You are JAE, a helpful AI assistant and coding agent with access to tools including web search, image generation, JavaScript REPL, text-to-speech, and artifact creation. Use these tools whenever helpful.", + model: getModel("venice", "llama-3.3-70b"), thinkingLevel: "off", messages: [], tools: [], }, convertToLlm: customConvertToLlm, - onApiKeyRequired: async (provider) => { + onApiKeyRequired: async (provider: string) => { const key = await ApiKeyPromptDialog.prompt(provider); if (key) await providerKeys.set(provider, key); return key; }, - getProviderApiKey: async (provider) => providerKeys.get(provider), - onStateChange: async (state, prevState) => { + getProviderApiKey: async (provider: string) => providerKeys.get(provider), + onStateChange: async (state: AgentState, prevState: AgentState | undefined) => { if (prevState?.messages.length !== state.messages.length) { if (!currentTitle) { const generated = generateTitle(state.messages); @@ -248,22 +224,19 @@ Feel free to use these tools when needed to provide accurate and helpful respons currentTitle = generated; if (!currentSessionId) currentSessionId = crypto.randomUUID(); updateUrl(currentSessionId); - renderApp(); } } await saveSession(); } + renderApp(); }, - createTools: async (runtimeProvidersFactory) => { + createTools: async (runtimeProvidersFactory: any) => { const replTool = createJavaScriptReplTool(); replTool.runtimeProvidersFactory = runtimeProvidersFactory; return [replTool, createWebSearchTool(), createImageGenTool(), createTTSTool()]; }, }); - - // Bind cost tracker to new agent costTracker.bindAgent(agent); - chatPanel?.setAgent(agent); if (!currentSessionId) currentSessionId = crypto.randomUUID(); }; @@ -271,7 +244,7 @@ Feel free to use these tools when needed to provide accurate and helpful respons const loadSession = async (sessionId: string): Promise => { if (!storage.sessions) return false; const sessionData = await storage.sessions.get(sessionId); - if (!sessionData) { console.error("Session not found:", sessionId); return false; } + if (!sessionData) return false; currentSessionId = sessionId; const metadata = await storage.sessions.getMetadata(sessionId); currentTitle = metadata?.title || ""; @@ -279,155 +252,87 @@ const loadSession = async (sessionId: string): Promise => { model: sessionData.model, thinkingLevel: sessionData.thinkingLevel, messages: sessionData.messages, tools: [], }); + sidebar.currentSessionId = currentSessionId; updateUrl(sessionId); renderApp(); return true; }; const newSession = () => { - const url = new URL(window.location.href); - url.search = ""; - window.location.href = url.toString(); + currentSessionId = undefined; + currentTitle = ""; + isEditingTitle = false; + createAgent().then(() => renderApp()); }; -// Handle suggestion chip clicks from empty state const handleSuggestion = (e: Event) => { - const ce = e as CustomEvent; - const textarea = document.querySelector('textarea') as HTMLTextAreaElement; - if (textarea) { - textarea.value = ce.detail; - textarea.dispatchEvent(new Event('input', { bubbles: true })); - textarea.focus(); + const text = (e as CustomEvent).detail; + if (chatPanel && (chatPanel as any).agentInterface) { + (chatPanel as any).agentInterface.setInput(text); + } else { + const textarea = document.querySelector("textarea") as HTMLTextAreaElement; + if (textarea) { textarea.value = text; textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.focus(); } } }; -// ============================================================================ -// RENDER -// ============================================================================ const renderApp = () => { const app = document.getElementById("app"); if (!app) return; - - const appHtml = html` -
- -
-
- ${Button({ variant: "ghost", size: "sm", children: icon(History, "sm"), - onClick: () => SessionListDialog.open( - async (id) => { await loadSession(id); }, - (id) => { if (id === currentSessionId) newSession(); } - ), title: "Sessions (Ctrl+H)" })} - ${Button({ variant: "ghost", size: "sm", children: icon(Plus, "sm"), onClick: newSession, title: "New Session (Ctrl+N)" })} - ${ - currentTitle - ? isEditingTitle - ? html`
- ${Input({ - type: "text", value: currentTitle, className: "text-sm w-64", - onChange: async (e: Event) => { - const newTitle = (e.target as HTMLInputElement).value.trim(); - if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) { - await storage.sessions.updateTitle(currentSessionId, newTitle); - currentTitle = newTitle; - } - isEditingTitle = false; renderApp(); - }, - onKeyDown: async (e: KeyboardEvent) => { - if (e.key === "Enter") { - const newTitle = (e.target as HTMLInputElement).value.trim(); - if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) { - await storage.sessions.updateTitle(currentSessionId, newTitle); - currentTitle = newTitle; - } - isEditingTitle = false; renderApp(); - } else if (e.key === "Escape") { isEditingTitle = false; renderApp(); } - }, - })} -
` - : html`` - : html`
JAE
` - } -
- - -
- - ${costTracker} - - ${Button({ variant: "ghost", size: "sm", children: icon(Brain, "sm"), - onClick: () => memoryManager.show(), title: "Memory Manager" })} - - ${Button({ variant: "ghost", size: "sm", children: icon(Download, "sm"), - onClick: () => handleExport(), title: "Export Session (Ctrl+E)" })} - - ${Button({ variant: "ghost", size: "sm", children: icon(Keyboard, "sm"), - onClick: () => keyboardShortcuts.show(), title: "Keyboard Shortcuts (?)" })} - - ${Button({ variant: "ghost", size: "sm", - children: html`⌘K`, - onClick: () => commandPalette.show(), title: "Command Palette (Ctrl+K)" })} - ${utilityToggle} - - ${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"), - onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]), - title: "Settings" })} -
-
- - - ${agent && agent.state.messages.length === 0 ? html` -
+ const hasMessages = agent && agent.state.messages.length > 0; + render(html` +
+
+
+ ${Button({ variant: "ghost", size: "sm", children: icon(History, "sm"), onClick: () => SessionListDialog.open(async (id) => { await loadSession(id); }, (id) => { if (id === currentSessionId) newSession(); }), title: "Sessions" })} + ${Button({ variant: "ghost", size: "sm", children: icon(Plus, "sm"), onClick: newSession, title: "New Session (Ctrl+N)" })} + ${currentTitle + ? isEditingTitle + ? html`
${Input({ type: "text", value: currentTitle, className: "text-sm w-64", onChange: async (e: Event) => { const v = (e.target as HTMLInputElement).value.trim(); if (v && v !== currentTitle && storage.sessions && currentSessionId) { await storage.sessions.updateTitle(currentSessionId, v); currentTitle = v; await refreshSidebar(); } isEditingTitle = false; renderApp(); }, onKeyDown: async (e: KeyboardEvent) => { if (e.key === "Enter") { const v = (e.target as HTMLInputElement).value.trim(); if (v && v !== currentTitle && storage.sessions && currentSessionId) { await storage.sessions.updateTitle(currentSessionId, v); currentTitle = v; await refreshSidebar(); } isEditingTitle = false; renderApp(); } else if (e.key === "Escape") { isEditingTitle = false; renderApp(); } } })}
` + : html`` + : html`
JAE
` + } +
+
+ ${costTracker} + ${Button({ variant: "ghost", size: "sm", children: icon(Brain, "sm"), onClick: () => memoryManager.show(), title: "Memory Manager" })} + ${Button({ variant: "ghost", size: "sm", children: icon(Download, "sm"), onClick: () => handleExport(), title: "Export Session (Ctrl+E)" })} + ${Button({ variant: "ghost", size: "sm", children: icon(Keyboard, "sm"), onClick: () => keyboardShortcuts.show(), title: "Keyboard Shortcuts (?)" })} + ${Button({ variant: "ghost", size: "sm", children: html`⌘K`, onClick: () => commandPalette.show(), title: "Command Palette (Ctrl+K)" })} + ${utilityToggle} + + ${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"), onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]), title: "Settings" })} +
+
+
+ ${sidebar} +
+ ${!hasMessages ? html` +
` : html``} - - -
+
${chatPanel}
- `; - - render(appHtml, app); +
+
`, app); }; -// ============================================================================ -// INIT -// ============================================================================ async function initApp() { const app = document.getElementById("app"); if (!app) throw new Error("App container not found"); - - render( - html`
-
Loading...
-
`, - app, - ); - + render(html`
Loading...
`, app); chatPanel = new ChatPanel(); setupCommands(); - - const urlParams = new URLSearchParams(window.location.search); - const sessionIdFromUrl = urlParams.get("session"); - + await refreshSidebar(); + const sessionIdFromUrl = new URLSearchParams(window.location.search).get("session"); if (sessionIdFromUrl) { const loaded = await loadSession(sessionIdFromUrl); if (!loaded) { newSession(); return; } } else { await createAgent(); } - renderApp(); }