Agent-JAE/packages/web-ui/example/src/components/session-sidebar.ts
2026-03-26 21:27:24 +00:00

152 lines
6.6 KiB
TypeScript

import type { SessionMetadata } from "@jaeswift/jae-web-ui";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@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<string> = 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`
<div class="flex flex-col h-full border-r border-border bg-background shrink-0" style="width:100%">
<div class="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
<span class="text-[11px] font-semibold text-muted-foreground uppercase tracking-widest">Chats</span>
<button @click=${() => this.onNewSession?.()}
class="w-6 h-6 flex items-center justify-center rounded hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
title="New chat">
<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 class="flex-1 overflow-y-auto py-1">
${
sorted.length === 0
? html`
<div class="px-4 py-10 text-center">
<div class="text-3xl mb-2">💬</div>
<div class="text-xs text-muted-foreground">No chats yet</div>
</div>
`
: sorted.map(
(s) => html`
<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
${s.id === this.currentSessionId ? "bg-secondary" : "hover:bg-secondary/50"}"
@click=${() => this.onLoadSession?.(s.id)}>
${
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 class="px-3 py-1.5 border-t border-border shrink-0">
<div class="text-[10px] text-muted-foreground text-center">
${sorted.length} chat${sorted.length !== 1 ? "s" : ""}
</div>
</div>
</div>
`;
}
}