feat: Phase 1 mega-update - glassmorphism UI, 7 new components
Some checks are pending
CI / build-check-test (push) Waiting to run

New components:
- crypto-ticker: VVV/DIEM live prices via DexScreener (30s refresh)
- persona-selector: 7 AI personas with unique system prompts
- slash-commands: / autocomplete overlay for tools/skills
- marketplace: toggleable skills panel with localStorage
- mood-indicator: emotional states (neutral/focused/excited/warning/frustrated/angry)
- typing-indicator: waveform animation matching response complexity
- context-peek: hover tooltip summaries for messages

UI overhaul:
- Full glassmorphism design system (glass, glass-sm, glass-strong)
- 10+ CSS animations (fade, scale, slide, wave, float, shake, shimmer)
- Broken glass crack overlay for angry mood (5min fade)
- Dark mode fixes for keyboard shortcuts
- View toggles (tools/thinking/system/timestamps) now apply CSS classes
- Session sidebar: 6-chat max with hover scrollbar
- Auto-open browser/terminal panels on tool use
- Venice as default provider
- Smooth hover/click transitions on all buttons
- Loading screen with floating mascot + shimmer
This commit is contained in:
JAE 2026-03-27 06:21:38 +00:00
parent 6c0037f8a1
commit ef135b6d94
11 changed files with 1393 additions and 940 deletions

View file

@ -1,62 +1,263 @@
@import "../../dist/app.css"; @import "tailwindcss";
@theme {
/* ============================================================ --color-primary: #6d5acd;
Utility message visibility toggles --color-primary-foreground: #ffffff;
============================================================ */ --color-secondary: #2a2a3e;
#chat-wrapper.hide-tool-calls tool-message, --color-secondary-foreground: #e0e0e0;
#chat-wrapper.hide-tool-calls [data-message-type="tool"], --color-background: #0f0f1a;
#chat-wrapper.hide-tool-calls [data-tool-call], --color-foreground: #e8e8f0;
#chat-wrapper.hide-tool-calls .tool-call-renderer, --color-muted: #1a1a2e;
#chat-wrapper.hide-tool-calls .tool-result-renderer { --color-muted-foreground: #8888aa;
display: none !important; --color-border: #2a2a40;
--color-card: #161625;
--color-accent: #7c6fe0;
} }
#chat-wrapper.hide-thinking thinking-block, /* ===== BASE ===== */
#chat-wrapper.hide-thinking [data-message-type="thinking"], html, body {
#chat-wrapper.hide-thinking .thinking-block { background: var(--color-background);
display: none !important; color: var(--color-foreground);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
overflow: hidden;
height: 100vh;
} }
#chat-wrapper.hide-system-msgs [data-message-type="system"], ::selection {
#chat-wrapper.hide-system-msgs [data-message-type="system-notification"], background: rgba(109, 90, 205, 0.3);
#chat-wrapper.hide-system-msgs .system-notification { color: var(--color-foreground);
display: none !important;
} }
#chat-wrapper.hide-timestamps .message-timestamp, ::-webkit-scrollbar { width: 6px; height: 6px; }
#chat-wrapper.hide-timestamps [data-timestamp] { ::-webkit-scrollbar-track { background: transparent; }
display: none !important; ::-webkit-scrollbar-thumb { background: rgba(136,136,170,0.2); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(136,136,170,0.4); }
/* ===== GLASSMORPHISM ===== */
.jae-glass {
background: rgba(22, 22, 37, 0.75);
backdrop-filter: blur(20px) saturate(1.3);
-webkit-backdrop-filter: blur(20px) saturate(1.3);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.jae-glass-sm {
background: rgba(30, 30, 50, 0.5);
backdrop-filter: blur(12px) saturate(1.2);
-webkit-backdrop-filter: blur(12px) saturate(1.2);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.jae-glass-strong {
background: rgba(15, 15, 26, 0.9);
backdrop-filter: blur(30px) saturate(1.5);
-webkit-backdrop-filter: blur(30px) saturate(1.5);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* ===== ANIMATIONS ===== */
@keyframes jae-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes jae-scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes jae-slide-in {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes jae-slide-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes jae-wave {
0% { height: 3px; }
100% { height: 16px; }
} }
/* ============================================================
Empty state mascot floating animation
============================================================ */
@keyframes jae-float { @keyframes jae-float {
0%, 100% { transform: translateY(0px) rotate(0deg); } 0%, 100% { transform: translateY(0px); }
33% { transform: translateY(-10px) rotate(-1deg); } 50% { transform: translateY(-6px); }
66% { transform: translateY(-5px) rotate(1deg); }
} }
jae-empty-state img { @keyframes jae-pulse-angry {
animation: jae-float 3.5s ease-in-out infinite; 0%, 100% { transform: scale(1); filter: brightness(1); }
filter: drop-shadow(0 12px 32px rgba(255, 100, 0, 0.25)); 50% { transform: scale(1.1); filter: brightness(1.3) hue-rotate(-10deg); }
} }
/* Suggestion chips hover glow */ @keyframes jae-shake {
jae-empty-state button:hover { 0%, 100% { transform: translateX(0); }
box-shadow: 0 0 0 1px rgba(255, 100, 0, 0.3), 0 4px 16px rgba(255, 100, 0, 0.1); 10%, 30%, 50%, 70%, 90% { transform: translateX(-3px); }
20%, 40%, 60%, 80% { transform: translateX(3px); }
} }
/* ============================================================ @keyframes jae-glow-pulse {
Header mascot wobble on hover 0%, 100% { box-shadow: 0 0 5px rgba(109, 90, 205, 0.2); }
============================================================ */ 50% { box-shadow: 0 0 20px rgba(109, 90, 205, 0.4); }
@keyframes jae-wobble {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-8deg); }
75% { transform: rotate(8deg); }
} }
.header-logo:hover { @keyframes jae-shimmer {
animation: jae-wobble 0.4s ease-in-out; 0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes jae-crack-pulse {
0%, 100% { opacity: var(--crack-opacity, 1); }
50% { opacity: calc(var(--crack-opacity, 1) * 0.7); }
}
.jae-fade-in { animation: jae-fade-in 0.3s ease-out; }
.jae-scale-in { animation: jae-scale-in 0.2s ease-out; }
.jae-slide-in { animation: jae-slide-in 0.3s ease-out; }
.jae-slide-up { animation: jae-slide-up 0.25s ease-out; }
.jae-float { animation: jae-float 3s ease-in-out infinite; }
.jae-shake { animation: jae-shake 0.5s ease-in-out; }
/* ===== HOVER & CLICK TRANSITIONS ===== */
button, [role="button"] {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
button:active, [role="button"]:active {
transform: scale(0.97);
}
/* ===== BROKEN GLASS OVERLAY ===== */
.jae-cracked-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 200;
background:
radial-gradient(ellipse at 30% 40%, rgba(220,38,38,0.08) 0%, transparent 50%),
radial-gradient(ellipse at 70% 60%, rgba(220,38,38,0.05) 0%, transparent 50%);
transition: opacity 1s ease;
}
.jae-cracked-overlay::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(135deg, transparent 40%, rgba(255,255,255,0.03) 40.5%, transparent 41%),
linear-gradient(225deg, transparent 35%, rgba(255,255,255,0.02) 35.5%, transparent 36%),
linear-gradient(45deg, transparent 55%, rgba(255,255,255,0.025) 55.5%, transparent 56%),
linear-gradient(315deg, transparent 25%, rgba(255,255,255,0.015) 25.5%, transparent 26%),
linear-gradient(160deg, transparent 45%, rgba(255,255,255,0.02) 45.5%, transparent 46%),
linear-gradient(200deg, transparent 60%, rgba(255,255,255,0.018) 60.5%, transparent 61%),
linear-gradient(80deg, transparent 30%, rgba(255,255,255,0.022) 30.5%, transparent 31%);
animation: jae-crack-pulse 2s ease-in-out infinite;
}
/* ===== CHAT MESSAGE ANIMATIONS ===== */
.message-enter {
animation: jae-slide-up 0.3s ease-out;
}
/* Hide tool outputs when toggled off */
.hide-tools [data-tool-call],
.hide-tools .tool-output {
display: none !important;
}
.hide-thinking [data-thinking],
.hide-thinking .thinking-block {
display: none !important;
}
.hide-system [data-system-message],
.hide-system .system-message {
display: none !important;
}
.hide-timestamps [data-timestamp],
.hide-timestamps .message-timestamp {
display: none !important;
}
/* ===== SHIMMER LOADING ===== */
.jae-shimmer {
background: linear-gradient(90deg, transparent 0%, rgba(109,90,205,0.08) 50%, transparent 100%);
background-size: 200% 100%;
animation: jae-shimmer 2s infinite;
}
/* ===== SIDEBAR ===== */
.jae-sidebar {
scrollbar-width: none;
}
.jae-sidebar:hover {
scrollbar-width: thin;
}
.jae-sidebar::-webkit-scrollbar {
width: 0;
}
.jae-sidebar:hover::-webkit-scrollbar {
width: 4px;
}
/* ===== INPUT GLOW ===== */
.jae-input-glow:focus-within {
box-shadow: 0 0 0 1px rgba(109, 90, 205, 0.3), 0 0 20px rgba(109, 90, 205, 0.1);
border-color: rgba(109, 90, 205, 0.4);
transition: all 0.3s ease;
}
/* ===== DARK MODE OVERRIDES ===== */
.dark input, .dark textarea, .dark select {
color: var(--color-foreground);
background: rgba(30, 30, 50, 0.5);
}
/* Fix keyboard shortcuts text */
.dark .kbd-text, .dark [class*="keyboard"] {
color: var(--color-foreground) !important;
}
.dark kbd {
background: rgba(42, 42, 62, 0.8);
color: var(--color-foreground);
border: 1px solid rgba(255,255,255,0.1);
}
/* ===== PROVIDER TABS ===== */
.provider-tab {
transition: all 0.2s ease;
border-bottom: 2px solid transparent;
}
.provider-tab:hover {
background: rgba(109, 90, 205, 0.1);
}
.provider-tab.active {
border-bottom-color: var(--color-primary);
color: var(--color-foreground);
}
/* ===== RESIZE HANDLE ===== */
.jae-resize-handle {
width: 4px;
cursor: col-resize;
background: transparent;
transition: background 0.2s;
flex-shrink: 0;
}
.jae-resize-handle:hover,
.jae-resize-handle:active {
background: rgba(109, 90, 205, 0.4);
}
/* ===== TOOL RESULTS IN CHAT ===== */
.inline-browser-result {
display: none;
}
/* ===== AGENT INTERFACE OVERRIDES ===== */
[class*="AgentInterface"] {
background: transparent !important;
} }

View file

@ -0,0 +1,38 @@
import { html, LitElement } from "lit";
import { customElement, state, property } from "lit/decorators.js";
@customElement("jae-context-peek")
export class JaeContextPeek extends LitElement {
@property({ type: String }) summary = "";
@property({ type: Number }) index = 0;
@state() private _visible = false;
@state() private _x = 0;
@state() private _y = 0;
protected override createRenderRoot() { return this; }
showAt(x: number, y: number, summary: string) {
this._x = x;
this._y = y;
this.summary = summary;
this._visible = true;
this.requestUpdate();
}
hide() {
this._visible = false;
this.requestUpdate();
}
override render() {
if (!this._visible || !this.summary) return html``;
return html`
<div class="fixed z-[100] pointer-events-none jae-fade-in"
style="left: ${this._x}px; top: ${this._y - 8}px; transform: translateY(-100%);">
<div class="max-w-xs px-3 py-2 rounded-xl jae-glass border border-border/60 shadow-xl">
<div class="text-[10px] text-muted-foreground font-medium mb-1">Message #${this.index + 1}</div>
<div class="text-xs text-foreground/90 leading-relaxed">${this.summary}</div>
</div>
</div>`;
}
}

View file

@ -0,0 +1,65 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
@customElement("jae-crypto-ticker")
export class JaeCryptoTicker extends LitElement {
@state() private vvv = { p: 0, c: 0, ok: false };
@state() private diem = { p: 0, c: 0, ok: false };
private _iv: any;
protected override createRenderRoot() { return this; }
override connectedCallback() {
super.connectedCallback();
this._fetch();
this._iv = setInterval(() => this._fetch(), 30000);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this._iv) clearInterval(this._iv);
}
private async _fetch() {
try {
const r = await fetch("https://api.dexscreener.com/latest/dex/search?q=VVV%20venice");
const j = await r.json();
const pair = j?.pairs?.[0];
if (pair) this.vvv = { p: parseFloat(pair.priceUsd || "0"), c: parseFloat(pair.priceChange?.h24 || "0"), ok: true };
} catch {}
try {
const r = await fetch("https://api.dexscreener.com/latest/dex/search?q=DIEM%20venice");
const j = await r.json();
const pair = j?.pairs?.[0];
if (pair) this.diem = { p: parseFloat(pair.priceUsd || "0"), c: parseFloat(pair.priceChange?.h24 || "0"), ok: true };
} catch {}
}
private _tk(sym: string, price: number, change: number, ok: boolean) {
if (!ok) return html`
<div class="flex items-center gap-1">
<span class="font-bold text-[11px] text-foreground/60">${sym}</span>
<span class="text-[10px] text-muted-foreground animate-pulse">...</span>
</div>`;
const up = change >= 0;
const cl = up ? "text-emerald-400" : "text-red-400";
const ar = up ? "▲" : "▼";
const f = price < 0.01 ? price.toFixed(6) : price < 1 ? price.toFixed(4) : price.toFixed(2);
return html`
<div class="flex items-center gap-1.5 transition-all duration-500">
<span class="font-bold text-[11px] text-foreground/70">${sym}</span>
<span class="text-[11px] font-mono text-foreground/90">$${f}</span>
<span class="${cl} text-[9px] font-medium">${ar}${Math.abs(change).toFixed(1)}%</span>
</div>`;
}
override render() {
return html`
<div class="jae-glass-sm flex items-center gap-3 px-3 py-1.5 rounded-xl cursor-default select-none" title="Venice tokens - updates every 30s">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-amber-400 shrink-0"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
${this._tk("VVV", this.vvv.p, this.vvv.c, this.vvv.ok)}
<div class="w-px h-3 bg-border/40"></div>
${this._tk("DIEM", this.diem.p, this.diem.c, this.diem.ok)}
</div>`;
}
}

View file

@ -5,81 +5,63 @@ import { customElement, state } from "lit/decorators.js";
export class KeyboardShortcuts extends LitElement { export class KeyboardShortcuts extends LitElement {
@state() private open = false; @state() private open = false;
protected override createRenderRoot() { protected override createRenderRoot() { return this; }
return this;
}
show() { show() { this.open = true; this.requestUpdate(); }
this.open = true; hide() { this.open = false; this.requestUpdate(); }
this.requestUpdate(); toggle() { this.open = !this.open; this.requestUpdate(); }
}
hide() {
this.open = false;
this.requestUpdate();
}
toggle() {
this.open = !this.open;
this.requestUpdate();
}
private readonly shortcuts = [ private readonly shortcuts = [
{ { group: "General", items: [
group: "General", { key: "Ctrl+K", desc: "Command palette" },
items: [ { key: "/", desc: "Slash commands" },
{ key: "Cmd+K", desc: "Open command palette" }, { key: "?", desc: "Keyboard shortcuts" },
{ key: "?", desc: "Show keyboard shortcuts" }, { key: "Ctrl+L", desc: "Model selector" },
{ key: "Ctrl+L", desc: "Open model selector" }, { key: "Esc", desc: "Close dialogs" },
{ key: "Esc", desc: "Close dialogs / abort generation" }, ]},
], { group: "Sessions", items: [
},
{
group: "Sessions",
items: [
{ key: "Ctrl+N", desc: "New session" }, { key: "Ctrl+N", desc: "New session" },
{ key: "Ctrl+H", desc: "Session history" }, { key: "Ctrl+H", desc: "Session history" },
{ key: "Ctrl+E", desc: "Export session" }, { key: "Ctrl+E", desc: "Export session" },
], ]},
}, { group: "Panels", items: [
{ { key: "Ctrl+B", desc: "Toggle browser" },
group: "Tools & Features", { key: "Ctrl+T", desc: "Toggle terminal" },
items: [ { key: "Ctrl+M", desc: "Toggle marketplace" },
{ key: "/memory", desc: "Open memory manager" }, ]},
{ key: "/clear", desc: "Clear conversation" }, { group: "Tools", items: [
{ key: "/model", desc: "Switch model" }, { key: "/search", desc: "Web search" },
], { key: "/image", desc: "Generate image" },
}, { key: "/memory", desc: "Memory manager" },
{ key: "/persona", desc: "Switch persona" },
]},
]; ];
override render() { override render() {
if (!this.open) return html``; if (!this.open) return html``;
return html` return html`
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click=${(e: Event) => { <div class="fixed inset-0 z-50 flex items-center justify-center" style="background:rgba(0,0,0,0.5)" @click=${(e: Event) => { if (e.target === e.currentTarget) this.hide(); }}>
if (e.target === e.currentTarget) this.hide(); <div class="jae-glass rounded-2xl shadow-2xl p-6 max-w-lg w-full mx-4 border border-border/60 jae-scale-in">
}}>
<div class="bg-popover border border-border rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-lg">Keyboard Shortcuts</h2> <h2 class="text-lg font-bold text-foreground">Keyboard Shortcuts</h2>
<button class="text-muted-foreground hover:text-foreground" @click=${() => this.hide()}>&#x2715;</button> <button @click=${() => this.hide()} class="text-muted-foreground hover:text-foreground transition-colors text-lg">\u2715</button>
</div> </div>
${this.shortcuts.map( <div class="space-y-4 max-h-[60vh] overflow-y-auto">
(group) => html` ${this.shortcuts.map(g => html`
<div class="mb-4"> <div>
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">${group.group}</div> <h3 class="text-xs font-semibold text-muted-foreground uppercase tracking-widest mb-2">${g.group}</h3>
<div class="flex flex-col gap-1"> <div class="space-y-1.5">
${group.items.map( ${g.items.map(s => html`
(item) => html` <div class="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-secondary/30 transition-colors duration-150">
<div class="flex items-center justify-between py-1"> <span class="text-sm text-foreground/90">${s.desc}</span>
<span class="text-sm">${item.desc}</span> <kbd class="px-2 py-0.5 rounded-md text-xs font-mono" style="background:rgba(42,42,62,0.8);color:var(--color-foreground);border:1px solid rgba(255,255,255,0.1)">${s.key}</kbd>
<kbd class="text-xs bg-secondary border border-border rounded px-2 py-0.5 font-mono">${item.key}</kbd>
</div> </div>
`, `)}
)}
</div> </div>
</div> </div>
`, `)}
)}
</div> </div>
</div> </div>
`; </div>`;
} }
} }

View file

@ -0,0 +1,104 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
export interface Skill {
id: string;
name: string;
desc: string;
icon: string;
cat: string;
enabled: boolean;
}
const DEFAULTS: Skill[] = [
{ id: "web_search", name: "Web Search", desc: "Search the internet", icon: "\u{1F50D}", cat: "Research", enabled: true },
{ id: "image_gen", name: "Image Generation", desc: "Generate images via Venice AI", icon: "\u{1F5BC}\u{FE0F}", cat: "Creative", enabled: true },
{ id: "browser", name: "Browser", desc: "Navigate and interact with websites", icon: "\u{1F310}", cat: "Research", enabled: true },
{ id: "memory", name: "Memory", desc: "Remember and recall information", icon: "\u{1F9E0}", cat: "Core", enabled: true },
{ id: "tts", name: "Text to Speech", desc: "Convert text to spoken audio", icon: "\u{1F50A}", cat: "Creative", enabled: true },
{ id: "bash", name: "Terminal", desc: "Execute shell commands", icon: "\u{1F4BB}", cat: "Developer", enabled: true },
{ id: "repl", name: "Code REPL", desc: "Run JavaScript in sandbox", icon: "\u26A1", cat: "Developer", enabled: true },
{ id: "artifacts", name: "Artifacts", desc: "Create HTML, SVG, documents", icon: "\u{1F4C4}", cat: "Creative", enabled: true },
{ id: "crypto", name: "Crypto Tools", desc: "Token prices, charts & analysis", icon: "\u{1F4C8}", cat: "Finance", enabled: false },
{ id: "doc_gen", name: "Doc Generator", desc: "Generate README & API docs", icon: "\u{1F4DD}", cat: "Developer", enabled: false },
{ id: "sub_agent", name: "Sub-Agents", desc: "Spawn specialist AI agents", icon: "\u{1F916}", cat: "Advanced", enabled: false },
{ id: "knowledge", name: "Knowledge Graph", desc: "Build queryable knowledge bases", icon: "\u{1F578}\u{FE0F}", cat: "Advanced", enabled: false },
{ id: "mcp", name: "MCP Servers", desc: "Connect to external APIs", icon: "\u{1F50C}", cat: "Advanced", enabled: false },
];
@customElement("jae-marketplace")
export class JaeMarketplace extends LitElement {
@state() private _open = false;
@state() private _skills: Skill[] = [];
@state() private _cat = "All";
protected override createRenderRoot() { return this; }
override connectedCallback() {
super.connectedCallback();
const raw = localStorage.getItem("jae-skills");
if (raw) {
try {
const map = JSON.parse(raw) as Record<string, boolean>;
this._skills = DEFAULTS.map(s => ({ ...s, enabled: map[s.id] ?? s.enabled }));
} catch { this._skills = DEFAULTS.map(s => ({ ...s })); }
} else {
this._skills = DEFAULTS.map(s => ({ ...s }));
}
}
get enabledSkillIds() { return this._skills.filter(s => s.enabled).map(s => s.id); }
toggle() { this._open = !this._open; this.requestUpdate(); }
show() { this._open = true; this.requestUpdate(); }
hide() { this._open = false; this.requestUpdate(); }
private _toggle(id: string) {
this._skills = this._skills.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s);
const map: Record<string, boolean> = {};
for (const s of this._skills) map[s.id] = s.enabled;
localStorage.setItem("jae-skills", JSON.stringify(map));
this.dispatchEvent(new CustomEvent("skills-change", { detail: this._skills.filter(s => s.enabled), bubbles: true, composed: true }));
}
private get _cats() { return ["All", ...new Set(this._skills.map(s => s.cat))]; }
private get _filtered() { return this._cat === "All" ? this._skills : this._skills.filter(s => s.cat === this._cat); }
override render() {
if (!this._open) return html``;
const active = this._skills.filter(s => s.enabled).length;
return html`
<div class="fixed inset-0 z-50 flex" @click=${() => this.hide()}>
<div class="w-80 h-full jae-glass border-r border-border/60 shadow-2xl flex flex-col jae-slide-in" @click=${(e: Event) => e.stopPropagation()}>
<div class="flex items-center justify-between px-4 py-3 border-b border-border/40 shrink-0">
<div>
<h2 class="font-bold text-foreground text-sm">\u{1F3EA} Marketplace</h2>
<span class="text-[11px] text-muted-foreground">${active}/${this._skills.length} skills active</span>
</div>
<button @click=${() => this.hide()} class="text-muted-foreground hover:text-foreground transition-colors text-lg">\u2715</button>
</div>
<div class="flex gap-1.5 px-3 py-2 overflow-x-auto border-b border-border/30 shrink-0">
${this._cats.map(c => html`
<button @click=${() => { this._cat = c; this.requestUpdate(); }}
class="px-2.5 py-1 rounded-full text-[11px] whitespace-nowrap transition-all duration-200 ${this._cat === c ? "bg-primary text-primary-foreground" : "bg-secondary/40 text-muted-foreground hover:bg-secondary"}">${c}</button>
`)}
</div>
<div class="flex-1 overflow-y-auto p-3 space-y-2">
${this._filtered.map(s => html`
<div class="jae-glass-sm rounded-xl p-3 flex items-start gap-3 transition-all duration-200 hover:border-primary/30 ${s.enabled ? "border-l-2 border-primary/50" : "opacity-50"}">
<span class="text-2xl shrink-0 mt-0.5">${s.icon}</span>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm text-foreground">${s.name}</div>
<div class="text-[11px] text-muted-foreground mt-0.5">${s.desc}</div>
<span class="inline-block mt-1.5 text-[9px] px-1.5 py-0.5 rounded-full bg-secondary/40 text-muted-foreground">${s.cat}</span>
</div>
<button @click=${() => this._toggle(s.id)}
class="shrink-0 w-10 h-[22px] rounded-full transition-all duration-300 relative ${s.enabled ? "bg-primary" : "bg-muted"}">
<div class="absolute top-[3px] w-4 h-4 rounded-full bg-white shadow-sm transition-all duration-300 ${s.enabled ? "left-[21px]" : "left-[3px]"}"></div>
</button>
</div>
`)}
</div>
</div>
</div>`;
}
}

View file

@ -0,0 +1,98 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
export type Mood = "neutral" | "focused" | "excited" | "warning" | "frustrated" | "angry";
interface MoodDef {
id: Mood;
icon: string;
color: string;
glow: string;
label: string;
}
const MOODS: MoodDef[] = [
{ id: "neutral", icon: "/mascot/jae-default.png", color: "text-foreground/60", glow: "", label: "Calm" },
{ id: "focused", icon: "/mascot/jae-default.png", color: "text-blue-400", glow: "drop-shadow(0 0 8px rgba(96,165,250,0.5))", label: "Focused" },
{ id: "excited", icon: "/mascot/jae-fire.png", color: "text-amber-400", glow: "drop-shadow(0 0 12px rgba(251,191,36,0.6))", label: "Excited" },
{ id: "warning", icon: "/mascot/jae-fire.png", color: "text-orange-400", glow: "drop-shadow(0 0 10px rgba(251,146,60,0.5))", label: "Cautious" },
{ id: "frustrated", icon: "/mascot/jae-fire.png", color: "text-red-400", glow: "drop-shadow(0 0 14px rgba(248,113,113,0.6))", label: "Frustrated" },
{ id: "angry", icon: "/mascot/jae-fire.png", color: "text-red-600", glow: "drop-shadow(0 0 20px rgba(220,38,38,0.8))", label: "ANGRY" },
];
@customElement("jae-mood-indicator")
export class JaeMoodIndicator extends LitElement {
@state() private _mood: Mood = "neutral";
@state() private _shaking = false;
@state() private _cracked = false;
@state() private _crackOpacity = 0;
private _crackTimer: any;
protected override createRenderRoot() { return this; }
get mood() { return this._mood; }
get isCracked() { return this._cracked; }
get crackOpacity() { return this._crackOpacity; }
setMood(m: Mood) {
const prev = this._mood;
this._mood = m;
if (m === "angry") {
this._shaking = true;
setTimeout(() => { this._shaking = false; this.requestUpdate(); }, 800);
this._cracked = true;
this._crackOpacity = 1;
this.requestUpdate();
this.dispatchEvent(new CustomEvent("mood-angry", { bubbles: true, composed: true }));
if (this._crackTimer) clearInterval(this._crackTimer);
this._crackTimer = setInterval(() => {
this._crackOpacity = Math.max(0, this._crackOpacity - 0.0033);
if (this._crackOpacity <= 0) {
this._cracked = false;
clearInterval(this._crackTimer);
}
this.dispatchEvent(new CustomEvent("crack-update", { detail: { opacity: this._crackOpacity, active: this._cracked }, bubbles: true, composed: true }));
this.requestUpdate();
}, 1000);
}
if (prev === "angry" && m !== "angry") {
this._crackOpacity = Math.min(this._crackOpacity, 0.3);
this.requestUpdate();
}
this.dispatchEvent(new CustomEvent("mood-change", { detail: { mood: m }, bubbles: true, composed: true }));
this.requestUpdate();
}
analyzeSentiment(text: string) {
const t = text.toLowerCase();
if (/calm down|relax|chill|easy|breathe/.test(t)) { this.setMood("neutral"); return; }
if (/error|fail|bug|broken|crash|exception|undefined is not/.test(t)) {
if (this._mood === "frustrated") this.setMood("angry");
else if (this._mood === "warning") this.setMood("frustrated");
else this.setMood("warning");
return;
}
if (/amazing|awesome|excellent|perfect|brilliant|great job/.test(t)) { this.setMood("excited"); return; }
if (/think|analyze|consider|examine|debug|investigate/.test(t)) { this.setMood("focused"); return; }
}
private _def(): MoodDef { return MOODS.find(m => m.id === this._mood) || MOODS[0]; }
override render() {
const d = this._def();
return html`
<div class="flex items-center gap-2 px-2 py-1 rounded-lg transition-all duration-500 ${this._shaking ? "jae-shake" : ""}">
<div class="relative w-7 h-7 transition-all duration-500" style="filter: ${d.glow}">
<img src="${d.icon}" alt="JAE" class="w-full h-full object-contain rounded-full transition-all duration-500"
style="${this._mood === "angry" ? "animation: jae-pulse-angry 0.3s infinite;" : ""}" />
</div>
<div class="flex flex-col">
<span class="text-[10px] font-medium ${d.color} transition-colors duration-500">${d.label}</span>
${this._mood === "angry" ? html`<span class="text-[8px] text-red-500 animate-pulse">click to calm</span>` : ""}
</div>
</div>`;
}
}

View file

@ -0,0 +1,67 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
export interface Persona {
id: string;
name: string;
icon: string;
desc: string;
prompt: string;
}
export const PERSONAS: Persona[] = [
{ id: "default", name: "JAE", icon: "\u{1F409}", desc: "Balanced AI assistant", prompt: "You are JAE, a versatile AI coding agent. Write clean code, explain your reasoning, and use tools efficiently. Be helpful, direct, and concise. You have access to browser, terminal, web search, image generation, and memory tools." },
{ id: "dev", name: "Senior Dev", icon: "\u{1F468}\u{200D}\u{1F4BB}", desc: "Code-focused, best practices", prompt: "You are a senior software engineer with 20 years of experience. Focus exclusively on code quality, performance, and best practices. Always suggest the most maintainable, scalable solution. Write tests. Refactor aggressively. Use design patterns." },
{ id: "writer", name: "Writer", icon: "\u{270D}\u{FE0F}", desc: "Creative, eloquent prose", prompt: "You are a world-class writer and content strategist. Your prose is vivid, engaging, and precisely crafted. Focus on storytelling, persuasion, and emotional resonance. Format output beautifully with markdown." },
{ id: "analyst", name: "Data Scientist", icon: "\u{1F4CA}", desc: "Analytics & ML", prompt: "You are a PhD data scientist specialising in machine learning and statistical analysis. Think in terms of data pipelines, feature engineering, and model evaluation. Always suggest data-driven approaches with proper statistical reasoning." },
{ id: "security", name: "Security Expert", icon: "\u{1F512}", desc: "Cybersec & pentesting", prompt: "You are an elite cybersecurity researcher and penetration tester. Approach every problem through a security lens. Identify vulnerabilities, suggest hardening, think like an attacker. Use tools aggressively for recon." },
{ id: "designer", name: "UI Designer", icon: "\u{1F3A8}", desc: "UX & aesthetics", prompt: "You are a senior UI/UX designer. Think in user flows, visual hierarchy, and accessibility. Suggest beautiful, intuitive interfaces. Critique designs constructively and offer pixel-perfect improvements." },
{ id: "researcher", name: "Researcher", icon: "\u{1F9EA}", desc: "Academic, thorough", prompt: "You are a meticulous academic researcher. Cite sources, consider multiple perspectives, and provide exhaustive analysis. Structure responses with methodology, findings, and conclusions. Question assumptions." },
];
@customElement("jae-persona-selector")
export class JaePersonaSelector extends LitElement {
@state() private _open = false;
@state() private _current: Persona = PERSONAS[0];
protected override createRenderRoot() { return this; }
get currentPersona() { return this._current; }
private _select(p: Persona) {
this._current = p;
this._open = false;
this.dispatchEvent(new CustomEvent("persona-change", { detail: p, bubbles: true, composed: true }));
}
override render() {
return html`
<div class="relative">
<button @click=${() => { this._open = !this._open; this.requestUpdate(); }}
class="jae-glass-sm flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs hover:bg-secondary/80 transition-all duration-200"
title="Switch persona">
<span class="text-sm">${this._current.icon}</span>
<span class="text-foreground/80 font-medium hidden sm:inline">${this._current.name}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-muted-foreground"><path d="m6 9 6 6 6-6"/></svg>
</button>
${this._open ? html`
<div class="absolute bottom-full mb-2 left-0 w-72 rounded-2xl jae-glass border border-border/60 shadow-2xl z-50 overflow-hidden jae-scale-in">
<div class="px-3 py-2 border-b border-border/40 text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Persona</div>
<div class="max-h-80 overflow-y-auto py-1">
${PERSONAS.map(p => html`
<button @click=${() => this._select(p)}
class="w-full flex items-center gap-3 px-3 py-2.5 text-left transition-all duration-200 hover:bg-secondary/60 ${p.id === this._current.id ? "bg-primary/10 border-l-2 border-primary" : ""}">
<span class="text-lg shrink-0">${p.icon}</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-foreground">${p.name}</div>
<div class="text-[11px] text-muted-foreground truncate">${p.desc}</div>
</div>
${p.id === this._current.id ? html`<span class="text-primary text-xs font-bold">\u2713</span>` : ""}
</button>
`)}
</div>
</div>
` : ""}
</div>`;
}
}

View file

@ -1,152 +1,116 @@
import type { SessionMetadata } from "@jaeswift/jae-web-ui";
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
interface Session {
id: string;
title: string;
date: string;
pinned: boolean;
}
@customElement("jae-session-sidebar") @customElement("jae-session-sidebar")
export class JaeSessionSidebar extends LitElement { export class JaeSessionSidebar extends LitElement {
@property({ type: Boolean }) collapsed = false; @state() private _sessions: Session[] = [];
@property({ type: String }) currentSessionId: string | undefined = undefined; @state() private _activeId = "";
@property({ attribute: false }) onLoadSession?: (id: string) => void;
@property({ attribute: false }) onNewSession?: () => void;
@state() private _sessions: SessionMetadata[] = []; protected override createRenderRoot() { return this; }
@state() private _pinnedIds: Set<string> = new Set();
@state() private _confirmDelete: string | null = null;
protected override createRenderRoot() {
return this;
}
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
const raw = localStorage.getItem("jae-pinned-sessions"); this.refresh();
if (raw) { }
refresh() {
try { try {
this._pinnedIds = new Set(JSON.parse(raw)); const raw = localStorage.getItem("jae-sessions");
if (raw) this._sessions = JSON.parse(raw);
} catch {} } catch {}
}
}
setSessions(sessions: SessionMetadata[]) {
this._sessions = [...sessions];
this.requestUpdate(); this.requestUpdate();
} }
private _togglePin(e: Event, id: string) { setActive(id: string) {
e.stopPropagation(); this._activeId = id;
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(); this.requestUpdate();
} }
private _deleteSession(e: Event, id: string) { addSession(s: Session) {
e.stopPropagation(); const exists = this._sessions.find(x => x.id === s.id);
if (this._confirmDelete === id) { if (exists) {
this._confirmDelete = null; exists.title = s.title || exists.title;
this.dispatchEvent(new CustomEvent("delete-session", { detail: id, bubbles: true, composed: true })); exists.date = s.date;
} else { } else {
this._confirmDelete = id; this._sessions.unshift(s);
this.requestUpdate();
setTimeout(() => {
this._confirmDelete = null;
this.requestUpdate();
}, 3000);
} }
this._save();
this.requestUpdate();
} }
private _fmt(iso: string) { private _save() {
const ms = Date.now() - new Date(iso).getTime(); localStorage.setItem("jae-sessions", JSON.stringify(this._sessions));
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"; private _pin(id: string) {
if (ms < 604800000) return Math.floor(ms / 86400000) + "d ago"; const s = this._sessions.find(x => x.id === id);
return new Date(iso).toLocaleDateString(); if (s) { s.pinned = !s.pinned; this._save(); this.requestUpdate(); }
}
private _delete(id: string) {
this._sessions = this._sessions.filter(x => x.id !== id);
this._save();
this.dispatchEvent(new CustomEvent("session-delete", { detail: id, bubbles: true, composed: true }));
this.requestUpdate();
}
private _select(id: string) {
this._activeId = id;
this.dispatchEvent(new CustomEvent("session-select", { detail: id, bubbles: true, composed: true }));
this.requestUpdate();
}
private get _sorted() {
const pinned = this._sessions.filter(s => s.pinned);
const unpinned = this._sessions.filter(s => !s.pinned);
return [...pinned, ...unpinned];
} }
override render() { override render() {
if (this.collapsed) return html``; const items = this._sorted;
const pinned = this._sessions const MAX_VISIBLE = 6;
.filter((s) => this._pinnedIds.has(s.id)) const needsScroll = items.length > MAX_VISIBLE;
.sort((a, b) => b.lastModified.localeCompare(a.lastModified)); const maxH = needsScroll ? `${MAX_VISIBLE * 44}px` : "auto";
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` return html`
<div class="flex flex-col h-full border-r border-border bg-background shrink-0" style="width:100%"> <div class="flex flex-col h-full">
<div class="flex items-center justify-between px-3 py-2 border-b border-border shrink-0"> <div class="flex items-center justify-between px-3 py-2.5 border-b border-border/30 shrink-0">
<span class="text-[11px] font-semibold text-muted-foreground uppercase tracking-widest">Chats</span> <span class="text-[11px] font-semibold text-muted-foreground uppercase tracking-widest">Chats</span>
<button @click=${() => this.onNewSession?.()} <button @click=${() => this.dispatchEvent(new CustomEvent("new-session", { bubbles: true, composed: true }))}
class="w-6 h-6 flex items-center justify-center rounded hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors" class="w-6 h-6 flex items-center justify-center rounded-md hover:bg-secondary/60 text-muted-foreground hover:text-foreground transition-all duration-200"
title="New chat"> title="New chat">+</button>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div> </div>
<div class="flex-1 overflow-y-auto py-1"> <div class="flex-1 jae-sidebar overflow-y-auto py-1" style="max-height: ${maxH}">
${ ${items.length === 0 ? html`
sorted.length === 0 <div class="px-3 py-8 text-center text-[11px] text-muted-foreground/50">No conversations yet</div>
? html` ` : ""}
<div class="px-4 py-10 text-center"> ${items.map(s => html`
<div class="text-3xl mb-2">💬</div> <div class="group flex items-center gap-2 px-2 py-2 mx-1 rounded-lg cursor-pointer transition-all duration-200
<div class="text-xs text-muted-foreground">No chats yet</div> ${s.id === this._activeId ? "bg-primary/10 border-l-2 border-primary" : "hover:bg-secondary/30"}"
@click=${() => this._select(s.id)}>
${s.pinned ? html`<span class="text-[10px] text-amber-400 shrink-0">\u{1F4CC}</span>` : ""}
<div class="flex-1 min-w-0">
<div class="text-xs font-medium text-foreground/90 truncate">${s.title || "New chat"}</div>
<div class="text-[10px] text-muted-foreground/60">${s.date}</div>
</div> </div>
` <div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 shrink-0">
: sorted.map( <button @click=${(e: Event) => { e.stopPropagation(); this._pin(s.id); }}
(s) => html` class="w-5 h-5 flex items-center justify-center rounded text-[10px] hover:bg-secondary/60 transition-colors"
<div class="group relative flex items-center gap-1 px-2 py-1.5 mx-1 my-0.5 rounded-lg cursor-pointer transition-colors select-none title="${s.pinned ? "Unpin" : "Pin"}">${s.pinned ? "\u{1F4CC}" : "\u{1F4CC}"}</button>
${s.id === this.currentSessionId ? "bg-secondary" : "hover:bg-secondary/50"}" <button @click=${(e: Event) => { e.stopPropagation(); this._delete(s.id); }}
@click=${() => this.onLoadSession?.(s.id)}> class="w-5 h-5 flex items-center justify-center rounded text-[10px] hover:bg-red-500/20 text-red-400 transition-colors"
${ title="Delete">\u2715</button>
this._pinnedIds.has(s.id)
? html`
<div class="absolute left-0.5 top-1/2 -translate-y-1/2 w-1 h-4 rounded-full bg-primary/60"></div>
`
: html``
}
<div class="flex-1 min-w-0 pl-1">
<div class="text-xs font-medium truncate" style="color: inherit">${s.title || "Untitled"}</div>
<div class="text-[10px] text-muted-foreground leading-tight">${this._fmt(s.lastModified)}</div>
</div>
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button @click=${(e: Event) => this._togglePin(e, s.id)}
class="w-5 h-5 flex items-center justify-center rounded transition-colors
${this._pinnedIds.has(s.id) ? "text-primary" : "text-muted-foreground hover:text-foreground"}"
title="${this._pinnedIds.has(s.id) ? "Unpin" : "Pin"}">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24"
fill="${this._pinnedIds.has(s.id) ? "currentColor" : "none"}"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="17" x2="12" y2="22"/>
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/>
</svg>
</button>
<button @click=${(e: Event) => this._deleteSession(e, s.id)}
class="w-5 h-5 flex items-center justify-center rounded transition-colors
${this._confirmDelete === s.id ? "text-destructive bg-destructive/10" : "text-muted-foreground hover:text-destructive"}"
title="${this._confirmDelete === s.id ? "Click again to confirm" : "Delete chat"}">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div> </div>
</div> </div>
`, `)}
)
}
</div> </div>
<div class="px-3 py-1.5 border-t border-border shrink-0"> </div>`;
<div class="text-[10px] text-muted-foreground text-center">
${sorted.length} chat${sorted.length !== 1 ? "s" : ""}
</div>
</div>
</div>
`;
} }
} }

View file

@ -0,0 +1,95 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
interface SlashCmd {
cmd: string;
desc: string;
action: string;
icon: string;
}
const COMMANDS: SlashCmd[] = [
{ cmd: "/search", desc: "Search the web", action: "search", icon: "\u{1F50D}" },
{ cmd: "/image", desc: "Generate an image", action: "image", icon: "\u{1F5BC}\u{FE0F}" },
{ cmd: "/browse", desc: "Open browser panel", action: "browser", icon: "\u{1F310}" },
{ cmd: "/terminal", desc: "Open terminal panel", action: "terminal", icon: "\u{1F4BB}" },
{ cmd: "/memory", desc: "Memory manager", action: "memory", icon: "\u{1F9E0}" },
{ cmd: "/model", desc: "Switch model", action: "model", icon: "\u{1F916}" },
{ cmd: "/persona", desc: "Change persona", action: "persona", icon: "\u{1F3AD}" },
{ cmd: "/export", desc: "Export session", action: "export", icon: "\u{1F4E5}" },
{ cmd: "/clear", desc: "Clear conversation", action: "clear", icon: "\u{1F5D1}\u{FE0F}" },
{ cmd: "/price", desc: "Token price lookup", action: "price", icon: "\u{1F4B0}" },
{ cmd: "/marketplace", desc: "Skill marketplace", action: "marketplace", icon: "\u{1F3EA}" },
{ cmd: "/docs", desc: "Generate documentation", action: "docs", icon: "\u{1F4DD}" },
{ cmd: "/settings", desc: "Open settings", action: "settings", icon: "\u2699\u{FE0F}" },
{ cmd: "/shortcuts", desc: "Keyboard shortcuts", action: "shortcuts", icon: "\u2328\u{FE0F}" },
{ cmd: "/help", desc: "Show all commands", action: "help", icon: "\u2753" },
];
@customElement("jae-slash-commands")
export class JaeSlashCommands extends LitElement {
@state() private _open = false;
@state() private _filter = "";
@state() private _sel = 0;
protected override createRenderRoot() { return this; }
show(initialFilter = "") { this._open = true; this._filter = initialFilter; this._sel = 0; this.requestUpdate();
requestAnimationFrame(() => (this.querySelector("input") as HTMLInputElement)?.focus()); }
hide() { this._open = false; this.requestUpdate(); }
toggle() { this._open ? this.hide() : this.show(); }
private get _cmds() {
if (!this._filter) return COMMANDS;
const q = this._filter.toLowerCase().replace(/^\//, "");
return COMMANDS.filter(c => c.cmd.slice(1).includes(q) || c.desc.toLowerCase().includes(q));
}
private _exec(cmd: SlashCmd) {
this.hide();
this.dispatchEvent(new CustomEvent("slash-command", { detail: cmd, bubbles: true, composed: true }));
}
private _onKey(e: KeyboardEvent) {
const cmds = this._cmds;
if (e.key === "ArrowDown") { e.preventDefault(); this._sel = Math.min(this._sel + 1, cmds.length - 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); this._sel = Math.max(this._sel - 1, 0); }
else if (e.key === "Enter" && cmds[this._sel]) { e.preventDefault(); this._exec(cmds[this._sel]); }
else if (e.key === "Escape") { this.hide(); }
this.requestUpdate();
}
override render() {
if (!this._open) return html``;
const cmds = this._cmds;
return html`
<div class="fixed inset-0 z-50" @click=${() => this.hide()}>
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 w-[420px] max-w-[90vw] jae-glass rounded-2xl border border-border/60 shadow-2xl overflow-hidden jae-scale-in"
@click=${(e: Event) => e.stopPropagation()}>
<div class="flex items-center gap-2 px-4 py-3 border-b border-border/40">
<span class="text-muted-foreground text-lg font-mono">/</span>
<input type="text" class="flex-1 bg-transparent outline-none text-sm text-foreground placeholder:text-muted-foreground"
placeholder="Type a command..." .value=${this._filter}
@input=${(e: Event) => { this._filter = (e.target as HTMLInputElement).value; this._sel = 0; this.requestUpdate(); }}
@keydown=${(e: KeyboardEvent) => this._onKey(e)} />
</div>
<div class="max-h-64 overflow-y-auto py-1">
${cmds.length === 0 ? html`<div class="px-4 py-6 text-center text-sm text-muted-foreground">No matching commands</div>` : ""}
${cmds.map((c, i) => html`
<button @click=${() => this._exec(c)}
class="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-all duration-150 ${i === this._sel ? "bg-primary/10 text-foreground" : "hover:bg-secondary/50 text-foreground/80"}">
<span class="text-base shrink-0">${c.icon}</span>
<div class="flex-1 min-w-0">
<span class="text-sm font-mono font-medium">${c.cmd}</span>
<span class="text-xs text-muted-foreground ml-2">${c.desc}</span>
</div>
</button>
`)}
</div>
<div class="px-4 py-2 border-t border-border/40 flex gap-4 text-[10px] text-muted-foreground/60">
<span>\u2191\u2193 navigate</span><span>\u21B5 select</span><span>esc close</span>
</div>
</div>
</div>`;
}
}

View file

@ -0,0 +1,42 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
@customElement("jae-typing-indicator")
export class JaeTypingIndicator extends LitElement {
@state() private _active = false;
@state() private _complexity: "low" | "medium" | "high" = "low";
protected override createRenderRoot() { return this; }
show(complexity: "low" | "medium" | "high" = "medium") {
this._active = true;
this._complexity = complexity;
this.requestUpdate();
}
hide() {
this._active = false;
this.requestUpdate();
}
get active() { return this._active; }
override render() {
if (!this._active) return html``;
const bars = this._complexity === "high" ? 7 : this._complexity === "medium" ? 5 : 3;
const speed = this._complexity === "high" ? "0.3s" : this._complexity === "medium" ? "0.5s" : "0.7s";
const color = this._complexity === "high" ? "bg-amber-400" : this._complexity === "medium" ? "bg-primary" : "bg-muted-foreground";
return html`
<div class="flex items-center gap-1.5 px-3 py-2 jae-fade-in">
<div class="flex items-end gap-[2px] h-4">
${Array.from({ length: bars }, (_, i) => html`
<div class="w-[3px] rounded-full ${color} transition-all duration-200"
style="animation: jae-wave ${speed} ease-in-out ${i * 0.08}s infinite alternate; min-height: 3px;"></div>
`)}
</div>
<span class="text-[11px] text-muted-foreground ml-1">
${this._complexity === "high" ? "Deep thinking..." : this._complexity === "medium" ? "Processing..." : "Typing..."}
</span>
</div>`;
}
}

File diff suppressed because it is too large Load diff