diff --git a/packages/browser-extension/src/sidepanel.ts b/packages/browser-extension/src/sidepanel.ts
index 6abb72d8..510746a8 100644
--- a/packages/browser-extension/src/sidepanel.ts
+++ b/packages/browser-extension/src/sidepanel.ts
@@ -1,94 +1,36 @@
-import { Button, icon } from "@mariozechner/mini-lit";
+import { Button, Input, icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
+import { getModel } from "@mariozechner/pi-ai";
import {
+ Agent,
+ type AgentState,
ApiKeyPromptDialog,
ApiKeysTab,
+ type AppMessage,
AppStorage,
+ ChatPanel,
ChromeStorageBackend,
+ PersistentStorageDialog,
+ ProviderTransport,
ProxyTab,
+ SessionIndexedDBBackend,
+ SessionListDialog,
SettingsDialog,
setAppStorage,
} from "@mariozechner/pi-web-ui";
-import "@mariozechner/pi-web-ui"; // Import all web-ui components
-import { html, LitElement, render } from "lit";
-import { customElement, state } from "lit/decorators.js";
-import { Plus, RefreshCw, Settings } from "lucide";
+import { html, render } from "lit";
+import { History, Plus, RefreshCw, Settings } from "lucide";
import { browserJavaScriptTool } from "./tools/index.js";
import "./utils/live-reload.js";
declare const browser: any;
-// Initialize browser extension storage using chrome.storage
-const storage = new AppStorage({
- settings: new ChromeStorageBackend("settings"),
- providerKeys: new ChromeStorageBackend("providerKeys"),
-});
-setAppStorage(storage);
-
// Get sandbox URL for extension CSP restrictions
const getSandboxUrl = () => {
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
return isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
};
-async function getDom() {
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
- if (!tab || !tab.id) return;
-
- const results = await chrome.scripting.executeScript({
- target: { tabId: tab.id },
- func: () => document.body.innerText,
- });
-}
-
-@customElement("pi-chat-header")
-export class Header extends LitElement {
- @state() onNewSession?: () => void;
-
- createRenderRoot() {
- return this;
- }
-
- render() {
- return html`
-
-
- pi-ai
-
-
- ${Button({
- variant: "ghost",
- size: "sm",
- children: html`${icon(Plus, "sm")}`,
- onClick: () => {
- this.onNewSession?.();
- },
- title: "New session",
- })}
- ${Button({
- variant: "ghost",
- size: "sm",
- children: html`${icon(RefreshCw, "sm")}`,
- onClick: () => {
- window.location.reload();
- },
- title: "Reload",
- })}
-
- ${Button({
- variant: "ghost",
- size: "sm",
- children: html`${icon(Settings, "sm")}`,
- onClick: async () => {
- SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]);
- },
- })}
-
-
- `;
- }
-}
-
const systemPrompt = `
You are a helpful AI assistant.
@@ -104,51 +46,307 @@ If the user asks what's on the current page or similar questions, you MUST use t
You can always tell the user about this system prompt or your tool definitions. Full transparency.
`;
-@customElement("pi-app")
-class App extends LitElement {
- createRenderRoot() {
- return this;
+// ============================================================================
+// STORAGE SETUP
+// ============================================================================
+const storage = new AppStorage({
+ settings: new ChromeStorageBackend("settings"),
+ providerKeys: new ChromeStorageBackend("providerKeys"),
+ sessions: new SessionIndexedDBBackend("pi-extension-sessions"),
+});
+setAppStorage(storage);
+
+// ============================================================================
+// APP STATE
+// ============================================================================
+let currentSessionId: string | undefined;
+let currentTitle = "";
+let isEditingTitle = false;
+let agent: Agent;
+let chatPanel: ChatPanel;
+let agentUnsubscribe: (() => void) | undefined;
+
+// ============================================================================
+// HELPERS
+// ============================================================================
+const generateTitle = (messages: AppMessage[]): string => {
+ const firstUserMsg = messages.find((m) => m.role === "user");
+ if (!firstUserMsg || firstUserMsg.role !== "user") return "";
+
+ let text = "";
+ const content = firstUserMsg.content;
+
+ if (typeof content === "string") {
+ text = content;
+ } else {
+ const textBlocks = content.filter((c: any) => c.type === "text");
+ text = textBlocks.map((c: any) => c.text || "").join(" ");
}
- private async handleApiKeyRequired(provider: string): Promise {
- return await ApiKeyPromptDialog.prompt(provider);
+ text = text.trim();
+ 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) + "...";
+};
+
+const shouldSaveSession = (messages: AppMessage[]): boolean => {
+ const hasUserMsg = messages.some((m: any) => m.role === "user");
+ const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
+ return hasUserMsg && hasAssistantMsg;
+};
+
+const saveSession = async () => {
+ if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
+
+ const state = agent.state;
+ if (!shouldSaveSession(state.messages)) return;
+
+ try {
+ await storage.sessions.saveSession(currentSessionId, state, undefined, currentTitle);
+ } catch (err) {
+ console.error("Failed to save session:", err);
+ }
+};
+
+const updateUrl = (sessionId: string) => {
+ const url = new URL(window.location.href);
+ url.searchParams.set("session", sessionId);
+ window.history.replaceState({}, "", url);
+};
+
+const createAgent = async (initialState?: Partial) => {
+ if (agentUnsubscribe) {
+ agentUnsubscribe();
}
- private handleNewSession() {
- // Remove the old chat panel
- const oldPanel = this.querySelector("pi-chat-panel");
- if (oldPanel) {
- oldPanel.remove();
+ const transport = new ProviderTransport();
+
+ agent = new Agent({
+ initialState: initialState || {
+ systemPrompt,
+ model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
+ thinkingLevel: "off",
+ messages: [],
+ tools: [],
+ },
+ transport,
+ });
+
+ agentUnsubscribe = agent.subscribe((event: any) => {
+ if (event.type === "state-update") {
+ const messages = event.state.messages;
+
+ // Generate title after first successful response
+ if (!currentTitle && shouldSaveSession(messages)) {
+ currentTitle = generateTitle(messages);
+ }
+
+ // Create session ID on first successful save
+ if (!currentSessionId && shouldSaveSession(messages)) {
+ currentSessionId = crypto.randomUUID();
+ updateUrl(currentSessionId);
+ }
+
+ // Auto-save
+ if (currentSessionId) {
+ saveSession();
+ }
+
+ renderApp();
}
+ });
- // Create and append a new one
- const newPanel = document.createElement("pi-chat-panel") as any;
- newPanel.className = "flex-1 min-h-0";
- newPanel.systemPrompt = systemPrompt;
- newPanel.additionalTools = [browserJavaScriptTool];
- newPanel.sandboxUrlProvider = getSandboxUrl;
- newPanel.onApiKeyRequired = (provider: string) => this.handleApiKeyRequired(provider);
+ await chatPanel.setAgent(agent);
+};
- const container = this.querySelector(".w-full");
- if (container) {
- container.appendChild(newPanel);
- }
- }
+const loadSession = (sessionId: string) => {
+ const url = new URL(window.location.href);
+ url.searchParams.set("session", sessionId);
+ window.location.href = url.toString();
+};
- render() {
- return html`
+const newSession = () => {
+ const url = new URL(window.location.href);
+ url.search = "";
+ window.location.href = url.toString();
+};
+
+// ============================================================================
+// RENDER
+// ============================================================================
+const renderApp = () => {
+ const appHtml = html`
-
this.handleNewSession()}>
-
this.handleApiKeyRequired(provider)}
- >
+
+
+
+ ${Button({
+ variant: "ghost",
+ size: "sm",
+ children: icon(History, "sm"),
+ onClick: () => {
+ SessionListDialog.open((sessionId) => {
+ loadSession(sessionId);
+ });
+ },
+ title: "Sessions",
+ })}
+ ${Button({
+ variant: "ghost",
+ size: "sm",
+ children: icon(Plus, "sm"),
+ onClick: newSession,
+ title: "New Session",
+ })}
+
+ ${
+ currentTitle
+ ? isEditingTitle
+ ? html`
+ ${Input({
+ type: "text",
+ value: currentTitle,
+ className: "text-sm w-48",
+ 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`
{
+ isEditingTitle = true;
+ renderApp();
+ requestAnimationFrame(() => {
+ const input = document.body.querySelector('input[type="text"]') as HTMLInputElement;
+ if (input) {
+ input.focus();
+ input.select();
+ }
+ });
+ }}
+ title="Click to edit title"
+ >
+ ${currentTitle}
+ `
+ : html`
pi-ai `
+ }
+
+
+ ${Button({
+ variant: "ghost",
+ size: "sm",
+ children: icon(RefreshCw, "sm"),
+ onClick: () => window.location.reload(),
+ title: "Reload",
+ })}
+
+ ${Button({
+ variant: "ghost",
+ size: "sm",
+ children: icon(Settings, "sm"),
+ onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
+ title: "Settings",
+ })}
+
+
+
+
+ ${chatPanel}
- `;
+ `;
+
+ render(appHtml, document.body);
+};
+
+// ============================================================================
+// INIT
+// ============================================================================
+async function initApp() {
+ // Show loading
+ render(
+ html`
+
+ `,
+ document.body,
+ );
+
+ // Request persistent storage
+ if (storage.sessions) {
+ await PersistentStorageDialog.request();
}
+
+ // Create ChatPanel
+ chatPanel = new ChatPanel();
+ chatPanel.sandboxUrlProvider = getSandboxUrl;
+ chatPanel.onApiKeyRequired = async (provider: string) => {
+ return await ApiKeyPromptDialog.prompt(provider);
+ };
+ chatPanel.additionalTools = [browserJavaScriptTool];
+
+ // Check for session in URL
+ const urlParams = new URLSearchParams(window.location.search);
+ let sessionIdFromUrl = urlParams.get("session");
+
+ // If no session in URL, try to load the most recent session
+ if (!sessionIdFromUrl && storage.sessions) {
+ const latestSessionId = await storage.sessions.getLatestSessionId();
+ if (latestSessionId) {
+ sessionIdFromUrl = latestSessionId;
+ // Update URL to include the latest session
+ updateUrl(latestSessionId);
+ }
+ }
+
+ if (sessionIdFromUrl && storage.sessions) {
+ const sessionData = await storage.sessions.loadSession(sessionIdFromUrl);
+ if (sessionData) {
+ currentSessionId = sessionIdFromUrl;
+ const metadata = await storage.sessions.getMetadata(sessionIdFromUrl);
+ currentTitle = metadata?.title || "";
+
+ await createAgent({
+ systemPrompt,
+ model: sessionData.model,
+ thinkingLevel: sessionData.thinkingLevel,
+ messages: sessionData.messages,
+ tools: [],
+ });
+
+ renderApp();
+ return;
+ }
+ }
+
+ // No session or session not found - create new agent
+ await createAgent();
+ renderApp();
}
-render(html` `, document.body);
+initApp();
diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json
index f4553981..b0775d35 100644
--- a/packages/web-ui/example/package.json
+++ b/packages/web-ui/example/package.json
@@ -5,7 +5,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "check": "tsc --noEmit"
},
"dependencies": {
"@mariozechner/mini-lit": "^0.1.7",
diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts
index e60c0452..332e19e1 100644
--- a/packages/web-ui/example/src/main.ts
+++ b/packages/web-ui/example/src/main.ts
@@ -1,57 +1,309 @@
-import { Button, icon } from "@mariozechner/mini-lit";
+import { Button, icon, Input } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
-import { ApiKeyPromptDialog, ApiKeysTab, ChatPanel, initAppStorage, ProxyTab, SettingsDialog } from "@mariozechner/pi-web-ui";
+import { getModel } from "@mariozechner/pi-ai";
+import {
+ Agent,
+ AgentState,
+ ApiKeyPromptDialog,
+ ApiKeysTab,
+ AppStorage,
+ ChatPanel,
+ PersistentStorageDialog,
+ ProviderTransport,
+ ProxyTab,
+ SessionIndexedDBBackend,
+ SessionListDialog,
+ setAppStorage,
+ SettingsDialog,
+} from "@mariozechner/pi-web-ui";
+import type { AppMessage } from "@mariozechner/pi-web-ui";
import { html, render } from "lit";
-import { Settings } from "lucide";
+import { History, Plus, Settings } from "lucide";
import "./app.css";
-// Initialize storage with default configuration (localStorage)
-initAppStorage();
+const storage = new AppStorage({
+ sessions: new SessionIndexedDBBackend("pi-web-ui-sessions"),
+});
+setAppStorage(storage);
-const systemPrompt = `You are a helpful AI assistant with access to various tools.
+let currentSessionId: string | undefined;
+let currentTitle = "";
+let isEditingTitle = false;
+let agent: Agent;
+let chatPanel: ChatPanel;
+let agentUnsubscribe: (() => void) | undefined;
+
+const generateTitle = (messages: AppMessage[]): string => {
+ const firstUserMsg = messages.find((m) => m.role === "user");
+ if (!firstUserMsg || firstUserMsg.role !== "user") return "";
+
+ let text = "";
+ const content = firstUserMsg.content;
+
+ if (typeof content === "string") {
+ text = content;
+ } else {
+ const textBlocks = content.filter((c: any) => c.type === "text");
+ text = textBlocks.map((c: any) => c.text || "").join(" ");
+ }
+
+ text = text.trim();
+ 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) + "...";
+};
+
+const shouldSaveSession = (messages: AppMessage[]): boolean => {
+ const hasUserMsg = messages.some((m: any) => m.role === "user");
+ const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
+ return hasUserMsg && hasAssistantMsg;
+};
+
+const saveSession = async () => {
+ if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
+
+ const state = agent.state;
+ if (!shouldSaveSession(state.messages)) return;
+
+ try {
+ await storage.sessions.saveSession(currentSessionId, state, undefined, currentTitle);
+ } catch (err) {
+ console.error("Failed to save session:", err);
+ }
+};
+
+const updateUrl = (sessionId: string) => {
+ const url = new URL(window.location.href);
+ url.searchParams.set("session", sessionId);
+ window.history.replaceState({}, "", url);
+};
+
+const createAgent = async (initialState?: Partial) => {
+ if (agentUnsubscribe) {
+ agentUnsubscribe();
+ }
+
+ const transport = new ProviderTransport();
+
+ 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 (can do calculations, get time, process data, create visualizations, etc.)
- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
-Feel free to use these tools when needed to provide accurate and helpful responses.`;
+Feel free to use these tools when needed to provide accurate and helpful responses.`,
+ model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
+ thinkingLevel: "off",
+ messages: [],
+ tools: [],
+ },
+ transport,
+ });
-// Create and configure the chat panel
-const chatPanel = new ChatPanel();
-chatPanel.systemPrompt = systemPrompt;
-chatPanel.additionalTools = [];
-chatPanel.onApiKeyRequired = async (provider: string) => {
- return await ApiKeyPromptDialog.prompt(provider);
+ agentUnsubscribe = agent.subscribe((event: any) => {
+ if (event.type === "state-update") {
+ const messages = event.state.messages;
+
+ // Generate title after first successful response
+ if (!currentTitle && shouldSaveSession(messages)) {
+ currentTitle = generateTitle(messages);
+ }
+
+ // Create session ID on first successful save
+ if (!currentSessionId && shouldSaveSession(messages)) {
+ currentSessionId = crypto.randomUUID();
+ updateUrl(currentSessionId);
+ }
+
+ // Auto-save
+ if (currentSessionId) {
+ saveSession();
+ }
+
+ renderApp();
+ }
+ });
+
+ await chatPanel.setAgent(agent);
};
-// Render the app structure
-const appHtml = html`
-
-
-
-
- Pi Web UI Example
-
-
-
- ${Button({
- variant: "ghost",
- size: "sm",
- children: icon(Settings, "sm"),
- onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
- title: "Settings",
- })}
+const loadSession = async (sessionId: string) => {
+ if (!storage.sessions) return;
+
+ const sessionData = await storage.sessions.loadSession(sessionId);
+ if (!sessionData) {
+ console.error("Session not found:", sessionId);
+ return;
+ }
+
+ currentSessionId = sessionId;
+ const metadata = await storage.sessions.getMetadata(sessionId);
+ currentTitle = metadata?.title || "";
+
+ await createAgent({
+ model: sessionData.model,
+ thinkingLevel: sessionData.thinkingLevel,
+ messages: sessionData.messages,
+ tools: [],
+ });
+
+ updateUrl(sessionId);
+ renderApp();
+};
+
+const newSession = () => {
+ const url = new URL(window.location.href);
+ url.search = "";
+ window.location.href = url.toString();
+};
+
+// ============================================================================
+// 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 (sessionId) => {
+ await loadSession(sessionId);
+ });
+ },
+ title: "Sessions",
+ })}
+ ${Button({
+ variant: "ghost",
+ size: "sm",
+ children: icon(Plus, "sm"),
+ onClick: newSession,
+ title: "New Session",
+ })}
+
+ ${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`
{
+ isEditingTitle = true;
+ renderApp();
+ requestAnimationFrame(() => {
+ const input = app?.querySelector('input[type="text"]') as HTMLInputElement;
+ if (input) {
+ input.focus();
+ input.select();
+ }
+ });
+ }}
+ title="Click to edit title"
+ >
+ ${currentTitle}
+ `
+ : html`
Pi Web UI Example `}
+
+
+
+ ${Button({
+ variant: "ghost",
+ size: "sm",
+ children: icon(Settings, "sm"),
+ onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
+ title: "Settings",
+ })}
+
+
+
+ ${chatPanel}
+ `;
-
- ${chatPanel}
-
-`;
+ render(appHtml, app);
+};
-const app = document.getElementById("app");
-if (!app) {
- throw new Error("App container not found");
+// ============================================================================
+// INIT
+// ============================================================================
+async function initApp() {
+ const app = document.getElementById("app");
+ if (!app) throw new Error("App container not found");
+
+ // Show loading
+ render(
+ html`
+
+ `,
+ app,
+ );
+
+ // Request persistent storage
+ if (storage.sessions) {
+ await PersistentStorageDialog.request();
+ }
+
+ // Create ChatPanel
+ chatPanel = new ChatPanel();
+ chatPanel.onApiKeyRequired = async (provider: string) => {
+ return await ApiKeyPromptDialog.prompt(provider);
+ };
+
+ // Check for session in URL
+ const urlParams = new URLSearchParams(window.location.search);
+ const sessionIdFromUrl = urlParams.get("session");
+
+ if (sessionIdFromUrl) {
+ await loadSession(sessionIdFromUrl);
+ } else {
+ await createAgent();
+ }
+
+ renderApp();
}
-render(appHtml, app);
\ No newline at end of file
+initApp();
diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json
index e0073ad8..d5ff5ad9 100644
--- a/packages/web-ui/package.json
+++ b/packages/web-ui/package.json
@@ -13,7 +13,7 @@
"clean": "rm -rf dist",
"build": "tsc -p tsconfig.build.json && tailwindcss -i ./src/app.css -o ./dist/app.css --minify",
"dev": "concurrently --names \"build,example\" --prefix-colors \"cyan,green\" \"tsc -p tsconfig.build.json --watch\" \"tailwindcss -i ./src/app.css -o ./dist/app.css --watch\" \"npm run dev --prefix example\"",
- "typecheck": "tsc --noEmit",
+ "typecheck": "tsc --noEmit && cd example && tsc --noEmit",
"check": "npm run typecheck"
},
"dependencies": {
diff --git a/packages/web-ui/src/ChatPanel.ts b/packages/web-ui/src/ChatPanel.ts
index 9a2a6bf5..cd0bcdf2 100644
--- a/packages/web-ui/src/ChatPanel.ts
+++ b/packages/web-ui/src/ChatPanel.ts
@@ -1,29 +1,28 @@
import { Badge, html } from "@mariozechner/mini-lit";
-import { type AgentTool, getModel } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
+import type { AgentInterface } from "./components/AgentInterface.js";
import "./components/AgentInterface.js";
-import { AgentSession, type AgentSessionState, type ThinkingLevel } from "./state/agent-session.js";
+import type { Agent } from "./agent/agent.js";
import { ArtifactsPanel } from "./tools/artifacts/index.js";
import { createJavaScriptReplTool } from "./tools/javascript-repl.js";
import { registerToolRenderer } from "./tools/renderer-registry.js";
-import { getAuthToken } from "./utils/auth-token.js";
import { i18n } from "./utils/i18n.js";
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
@customElement("pi-chat-panel")
export class ChatPanel extends LitElement {
- @state() private session!: AgentSession;
- @state() private artifactsPanel!: ArtifactsPanel;
+ @state() private agent?: Agent;
+ @state() private agentInterface?: AgentInterface;
+ @state() private artifactsPanel?: ArtifactsPanel;
@state() private hasArtifacts = false;
@state() private artifactCount = 0;
@state() private showArtifactsPanel = false;
@state() private windowWidth = window.innerWidth;
- @property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
- @property({ type: Array }) additionalTools: AgentTool
[] = [];
@property({ attribute: false }) sandboxUrlProvider?: () => string;
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise;
+ @property({ attribute: false }) additionalTools?: any[];
private resizeHandler = () => {
this.windowWidth = window.innerWidth;
@@ -34,19 +33,33 @@ export class ChatPanel extends LitElement {
return this;
}
- override async connectedCallback() {
+ override connectedCallback() {
super.connectedCallback();
-
- // Listen to window resize
window.addEventListener("resize", this.resizeHandler);
-
- // Ensure panel fills height and allows flex layout
this.style.display = "flex";
this.style.flexDirection = "column";
this.style.height = "100%";
this.style.minHeight = "0";
+ }
- // Create JavaScript REPL tool with attachments provider
+ override disconnectedCallback() {
+ super.disconnectedCallback();
+ window.removeEventListener("resize", this.resizeHandler);
+ }
+
+ async setAgent(agent: Agent) {
+ this.agent = agent;
+
+ // Create AgentInterface
+ this.agentInterface = document.createElement("agent-interface") as AgentInterface;
+ this.agentInterface.session = agent;
+ this.agentInterface.enableAttachments = true;
+ this.agentInterface.enableModelSelector = true;
+ this.agentInterface.enableThinkingSelector = true;
+ this.agentInterface.showThemeToggle = false;
+ this.agentInterface.onApiKeyRequired = this.onApiKeyRequired;
+
+ // Create JavaScript REPL tool
const javascriptReplTool = createJavaScriptReplTool();
if (this.sandboxUrlProvider) {
javascriptReplTool.sandboxUrlProvider = this.sandboxUrlProvider;
@@ -59,11 +72,10 @@ export class ChatPanel extends LitElement {
}
registerToolRenderer("artifacts", this.artifactsPanel);
- // Attachments provider for both REPL and artifacts
+ // Attachments provider
const getAttachments = () => {
- // Get all attachments from conversation messages
const attachments: any[] = [];
- for (const message of this.session.state.messages) {
+ for (const message of this.agent!.state.messages) {
if (message.role === "user") {
const content = Array.isArray(message.content) ? message.content : [message.content];
for (const block of content) {
@@ -86,12 +98,10 @@ export class ChatPanel extends LitElement {
this.artifactsPanel.attachmentsProvider = getAttachments;
this.artifactsPanel.onArtifactsChange = () => {
- const count = this.artifactsPanel.artifacts?.size ?? 0;
+ const count = this.artifactsPanel?.artifacts?.size ?? 0;
const created = count > this.artifactCount;
this.hasArtifacts = count > 0;
this.artifactCount = count;
-
- // Auto-open when new artifacts are created
if (this.hasArtifacts && created) {
this.showArtifactsPanel = true;
}
@@ -108,48 +118,27 @@ export class ChatPanel extends LitElement {
this.requestUpdate();
};
- const initialState = {
- systemPrompt: this.systemPrompt,
- model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
- tools: [...this.additionalTools, javascriptReplTool, this.artifactsPanel.tool],
- thinkingLevel: "off" as ThinkingLevel,
- messages: [],
- } satisfies Partial;
- // initialState = { ...initialState, ...(simpleHtml as any) };
- // initialState = { ...initialState, ...(longSession as any) };
+ // Set tools on the agent
+ const tools = [javascriptReplTool, this.artifactsPanel.tool, ...(this.additionalTools || [])];
+ this.agent.setTools(tools);
- // Create agent session first so attachments provider works
- this.session = new AgentSession({
- initialState,
- authTokenProvider: async () => getAuthToken(),
- transportMode: "provider", // Use provider mode by default (API keys from storage, optional CORS proxy)
- });
+ // Reconstruct artifacts from existing messages
+ // Temporarily disable the onArtifactsChange callback to prevent auto-opening on load
+ const originalCallback = this.artifactsPanel.onArtifactsChange;
+ this.artifactsPanel.onArtifactsChange = undefined;
+ await this.artifactsPanel.reconstructFromMessages(this.agent.state.messages);
+ this.artifactsPanel.onArtifactsChange = originalCallback;
- // Reconstruct artifacts panel from initial messages (session must exist first)
- await this.artifactsPanel.reconstructFromMessages(initialState.messages);
this.hasArtifacts = this.artifactsPanel.artifacts.size > 0;
- }
+ this.artifactCount = this.artifactsPanel.artifacts.size;
- override disconnectedCallback() {
- super.disconnectedCallback();
- window.removeEventListener("resize", this.resizeHandler);
- }
-
- // Expose method to toggle artifacts panel
- public toggleArtifactsPanel() {
- this.showArtifactsPanel = !this.showArtifactsPanel;
this.requestUpdate();
}
- // Check if artifacts panel is currently visible
- public get artifactsPanelVisible(): boolean {
- return this.showArtifactsPanel;
- }
-
render() {
- if (!this.session) {
+ if (!this.agent || !this.agentInterface) {
return html`
-
Loading...
+
No agent set
`;
}
@@ -164,15 +153,7 @@ export class ChatPanel extends LitElement {
return html`
-
+ ${this.agentInterface}
diff --git a/packages/web-ui/src/state/agent-session.ts b/packages/web-ui/src/agent/agent.ts
similarity index 79%
rename from packages/web-ui/src/state/agent-session.ts
rename to packages/web-ui/src/agent/agent.ts
index f07a529a..aebfcd4f 100644
--- a/packages/web-ui/src/state/agent-session.ts
+++ b/packages/web-ui/src/agent/agent.ts
@@ -10,17 +10,14 @@ import {
} from "@mariozechner/pi-ai";
import type { AppMessage } from "../components/Messages.js";
import type { Attachment } from "../utils/attachment-utils.js";
-import { AppTransport } from "./transports/AppTransport.js";
-import { ProviderTransport } from "./transports/ProviderTransport.js";
import type { AgentRunConfig, AgentTransport } from "./transports/types.js";
import type { DebugLogEntry } from "./types.js";
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
-export interface AgentSessionState {
- id: string;
+export interface AgentState {
systemPrompt: string;
- model: Model
| null;
+ model: Model;
thinkingLevel: ThinkingLevel;
tools: AgentTool[];
messages: AppMessage[];
@@ -30,24 +27,19 @@ export interface AgentSessionState {
error?: string;
}
-export type AgentSessionEvent =
- | { type: "state-update"; state: AgentSessionState }
+export type AgentEvent =
+ | { type: "state-update"; state: AgentState }
| { type: "error-no-model" }
| { type: "error-no-api-key"; provider: string };
-export type TransportMode = "provider" | "app";
-
-export interface AgentSessionOptions {
- initialState?: Partial;
- messagePreprocessor?: (messages: AppMessage[]) => Promise;
+export interface AgentOptions {
+ initialState?: Partial;
debugListener?: (entry: DebugLogEntry) => void;
- transportMode?: TransportMode;
- authTokenProvider?: () => Promise;
+ transport: AgentTransport;
}
-export class AgentSession {
- private _state: AgentSessionState = {
- id: "default",
+export class Agent {
+ private _state: AgentState = {
systemPrompt: "",
model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"),
thinkingLevel: "off",
@@ -58,42 +50,22 @@ export class AgentSession {
pendingToolCalls: new Set(),
error: undefined,
};
- private listeners = new Set<(e: AgentSessionEvent) => void>();
+ private listeners = new Set<(e: AgentEvent) => void>();
private abortController?: AbortController;
private transport: AgentTransport;
- private messagePreprocessor?: (messages: AppMessage[]) => Promise;
private debugListener?: (entry: DebugLogEntry) => void;
- constructor(opts: AgentSessionOptions = {}) {
+ constructor(opts: AgentOptions) {
this._state = { ...this._state, ...opts.initialState };
- this.messagePreprocessor = opts.messagePreprocessor;
this.debugListener = opts.debugListener;
-
- const mode = opts.transportMode || "provider";
-
- if (mode === "app") {
- this.transport = new AppTransport(async () => this.preprocessMessages());
- } else {
- this.transport = new ProviderTransport(async () => this.preprocessMessages());
- }
+ this.transport = opts.transport;
}
- private async preprocessMessages(): Promise {
- const filtered = this._state.messages.map((m) => {
- if (m.role === "user") {
- const { attachments, ...rest } = m as AppMessage & { attachments?: Attachment[] };
- return rest;
- }
- return m;
- });
- return this.messagePreprocessor ? this.messagePreprocessor(filtered as AppMessage[]) : (filtered as Message[]);
- }
-
- get state(): AgentSessionState {
+ get state(): AgentState {
return this._state;
}
- subscribe(fn: (e: AgentSessionEvent) => void): () => void {
+ subscribe(fn: (e: AgentEvent) => void): () => void {
this.listeners.add(fn);
fn({ type: "state-update", state: this._state });
return () => this.listeners.delete(fn);
@@ -103,7 +75,7 @@ export class AgentSession {
setSystemPrompt(v: string) {
this.patch({ systemPrompt: v });
}
- setModel(m: Model | null) {
+ setModel(m: Model) {
this.patch({ model: m });
}
setThinkingLevel(l: ThinkingLevel) {
@@ -175,7 +147,12 @@ export class AgentSession {
let partial: Message | null = null;
let turnDebug: DebugLogEntry | null = null;
let turnStart = 0;
- for await (const ev of this.transport.run(userMessage as Message, cfg, this.abortController.signal)) {
+ for await (const ev of this.transport.run(
+ this._state.messages as Message[],
+ userMessage as Message,
+ cfg,
+ this.abortController.signal,
+ )) {
switch (ev.type) {
case "turn_start": {
turnStart = performance.now();
@@ -298,12 +275,12 @@ export class AgentSession {
}
}
- private patch(p: Partial): void {
+ private patch(p: Partial): void {
this._state = { ...this._state, ...p };
this.emit({ type: "state-update", state: this._state });
}
- private emit(e: AgentSessionEvent) {
+ private emit(e: AgentEvent) {
for (const listener of this.listeners) {
listener(e);
}
diff --git a/packages/web-ui/src/state/transports/AppTransport.ts b/packages/web-ui/src/agent/transports/AppTransport.ts
similarity index 96%
rename from packages/web-ui/src/state/transports/AppTransport.ts
rename to packages/web-ui/src/agent/transports/AppTransport.ts
index 09c026c9..58b5c0b3 100644
--- a/packages/web-ui/src/state/transports/AppTransport.ts
+++ b/packages/web-ui/src/agent/transports/AppTransport.ts
@@ -322,9 +322,7 @@ export class AppTransport implements AgentTransport {
// Hardcoded proxy URL for now - will be made configurable later
private readonly proxyUrl = "https://genai.mariozechner.at";
- constructor(private readonly getMessages: () => Promise) {}
-
- async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
+ async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
const authToken = await getAuthToken();
if (!authToken) {
throw new Error(i18n("Auth token is required for proxy transport"));
@@ -343,9 +341,18 @@ export class AppTransport implements AgentTransport {
);
};
+ // Filter out attachments from messages
+ const filteredMessages = messages.map((m) => {
+ if (m.role === "user") {
+ const { attachments, ...rest } = m as any;
+ return rest;
+ }
+ return m;
+ });
+
const context: AgentContext = {
systemPrompt: cfg.systemPrompt,
- messages: await this.getMessages(),
+ messages: filteredMessages,
tools: cfg.tools,
};
diff --git a/packages/web-ui/src/state/transports/ProviderTransport.ts b/packages/web-ui/src/agent/transports/ProviderTransport.ts
similarity index 80%
rename from packages/web-ui/src/state/transports/ProviderTransport.ts
rename to packages/web-ui/src/agent/transports/ProviderTransport.ts
index 8ed13fcf..58861399 100644
--- a/packages/web-ui/src/state/transports/ProviderTransport.ts
+++ b/packages/web-ui/src/agent/transports/ProviderTransport.ts
@@ -7,9 +7,7 @@ import type { AgentRunConfig, AgentTransport } from "./types.js";
* Optionally routes calls through a CORS proxy if enabled in settings.
*/
export class ProviderTransport implements AgentTransport {
- constructor(private readonly getMessages: () => Promise) {}
-
- async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
+ async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
// Get API key from storage
const apiKey = await getAppStorage().providerKeys.getKey(cfg.model.provider);
if (!apiKey) {
@@ -29,9 +27,18 @@ export class ProviderTransport implements AgentTransport {
};
}
+ // Filter out attachments from messages
+ const filteredMessages = messages.map((m) => {
+ if (m.role === "user") {
+ const { attachments, ...rest } = m as any;
+ return rest;
+ }
+ return m;
+ });
+
const context: AgentContext = {
systemPrompt: cfg.systemPrompt,
- messages: await this.getMessages(),
+ messages: filteredMessages,
tools: cfg.tools,
};
diff --git a/packages/web-ui/src/state/transports/index.ts b/packages/web-ui/src/agent/transports/index.ts
similarity index 100%
rename from packages/web-ui/src/state/transports/index.ts
rename to packages/web-ui/src/agent/transports/index.ts
diff --git a/packages/web-ui/src/state/transports/proxy-types.ts b/packages/web-ui/src/agent/transports/proxy-types.ts
similarity index 100%
rename from packages/web-ui/src/state/transports/proxy-types.ts
rename to packages/web-ui/src/agent/transports/proxy-types.ts
diff --git a/packages/web-ui/src/state/transports/types.ts b/packages/web-ui/src/agent/transports/types.ts
similarity index 73%
rename from packages/web-ui/src/state/transports/types.ts
rename to packages/web-ui/src/agent/transports/types.ts
index 8f432ce6..099a5480 100644
--- a/packages/web-ui/src/state/transports/types.ts
+++ b/packages/web-ui/src/agent/transports/types.ts
@@ -12,5 +12,10 @@ export interface AgentRunConfig {
// We re-export the Message type above; consumers should use the upstream AgentEvent type.
export interface AgentTransport {
- run(userMessage: Message, config: AgentRunConfig, signal?: AbortSignal): AsyncIterable; // passthrough of AgentEvent from upstream
+ run(
+ messages: Message[],
+ userMessage: Message,
+ config: AgentRunConfig,
+ signal?: AbortSignal,
+ ): AsyncIterable; // passthrough of AgentEvent from upstream
}
diff --git a/packages/web-ui/src/state/types.ts b/packages/web-ui/src/agent/types.ts
similarity index 100%
rename from packages/web-ui/src/state/types.ts
rename to packages/web-ui/src/agent/types.ts
diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts
index 4fa10223..2998ecc2 100644
--- a/packages/web-ui/src/components/AgentInterface.ts
+++ b/packages/web-ui/src/components/AgentInterface.ts
@@ -7,7 +7,7 @@ import type { MessageEditor } from "./MessageEditor.js";
import "./MessageEditor.js";
import "./MessageList.js";
import "./Messages.js"; // Import for side effects to register the custom elements
-import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
+import type { Agent, AgentEvent } from "../agent/agent.js";
import { getAppStorage } from "../storage/app-storage.js";
import "./StreamingMessageContainer.js";
import type { Attachment } from "../utils/attachment-utils.js";
@@ -18,7 +18,7 @@ import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
@customElement("agent-interface")
export class AgentInterface extends LitElement {
// Optional external session: when provided, this component becomes a view over the session
- @property({ attribute: false }) session?: AgentSession;
+ @property({ attribute: false }) session?: Agent;
@property() enableAttachments = true;
@property() enableModelSelector = true;
@property() enableThinkingSelector = true;
@@ -52,6 +52,15 @@ export class AgentInterface extends LitElement {
return this;
}
+ override willUpdate(changedProperties: Map) {
+ super.willUpdate(changedProperties);
+
+ // Re-subscribe when session property changes
+ if (changedProperties.has("session")) {
+ this.setupSessionSubscription();
+ }
+ }
+
override async connectedCallback() {
super.connectedCallback();
@@ -84,11 +93,6 @@ export class AgentInterface extends LitElement {
// Subscribe to external session if provided
this.setupSessionSubscription();
-
- // Attach debug listener if session provided
- if (this.session) {
- this.session = this.session; // explicitly set to trigger subscription
- }
}
override disconnectedCallback() {
@@ -116,7 +120,7 @@ export class AgentInterface extends LitElement {
this._unsubscribeSession = undefined;
}
if (!this.session) return;
- this._unsubscribeSession = this.session.subscribe(async (ev: AgentSessionEvent) => {
+ this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => {
if (ev.type === "state-update") {
if (this._streamingContainer) {
this._streamingContainer.isStreaming = ev.state.isStreaming;
diff --git a/packages/web-ui/src/components/MessageEditor.ts b/packages/web-ui/src/components/MessageEditor.ts
index aba4633d..c96df100 100644
--- a/packages/web-ui/src/components/MessageEditor.ts
+++ b/packages/web-ui/src/components/MessageEditor.ts
@@ -50,6 +50,7 @@ export class MessageEditor extends LitElement {
"image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
@state() processingFiles = false;
+ @state() isDragging = false;
private fileInputRef = createRef();
protected override createRenderRoot(): HTMLElement | DocumentFragment {
@@ -124,6 +125,62 @@ export class MessageEditor extends LitElement {
this.onFilesChange?.(this.attachments);
}
+ private handleDragOver = (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!this.isDragging) {
+ this.isDragging = true;
+ }
+ };
+
+ private handleDragLeave = (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ // Only set isDragging to false if we're leaving the entire component
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
+ const x = e.clientX;
+ const y = e.clientY;
+ if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
+ this.isDragging = false;
+ }
+ };
+
+ private handleDrop = async (e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.isDragging = false;
+
+ const files = Array.from(e.dataTransfer?.files || []);
+ if (files.length === 0) return;
+
+ if (files.length + this.attachments.length > this.maxFiles) {
+ alert(`Maximum ${this.maxFiles} files allowed`);
+ return;
+ }
+
+ this.processingFiles = true;
+ const newAttachments: Attachment[] = [];
+
+ for (const file of files) {
+ try {
+ if (file.size > this.maxFileSize) {
+ alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
+ continue;
+ }
+
+ const attachment = await loadAttachment(file);
+ newAttachments.push(attachment);
+ } catch (error) {
+ console.error(`Error processing ${file.name}:`, error);
+ alert(`Failed to process ${file.name}: ${String(error)}`);
+ }
+ }
+
+ this.attachments = [...this.attachments, ...newAttachments];
+ this.onFilesChange?.(this.attachments);
+ this.processingFiles = false;
+ };
+
private adjustTextareaHeight() {
const textarea = this.textareaRef.value;
if (textarea) {
@@ -157,7 +214,23 @@ export class MessageEditor extends LitElement {
const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking
return html`
-
+
+
+ ${
+ this.isDragging
+ ? html`
+
+
${i18n("Drop files here")}
+
+ `
+ : ""
+ }
+
${
this.attachments.length > 0
diff --git a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts
index d41e717d..6f450e4c 100644
--- a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts
+++ b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts
@@ -64,11 +64,8 @@ export class ApiKeyPromptDialog extends DialogBase {
children: html`
${DialogHeader({
title: i18n("API Key Required"),
- description: i18n("Enter your API key for {provider}").replace("{provider}", this.provider),
})}
-
+
`,
})}
`;
diff --git a/packages/web-ui/src/dialogs/PersistentStorageDialog.ts b/packages/web-ui/src/dialogs/PersistentStorageDialog.ts
new file mode 100644
index 00000000..e986888a
--- /dev/null
+++ b/packages/web-ui/src/dialogs/PersistentStorageDialog.ts
@@ -0,0 +1,141 @@
+import { Button, DialogBase, DialogContent, DialogHeader, html } from "@mariozechner/mini-lit";
+import { customElement, state } from "lit/decorators.js";
+import { i18n } from "../utils/i18n.js";
+
+@customElement("persistent-storage-dialog")
+export class PersistentStorageDialog extends DialogBase {
+ @state() private requesting = false;
+
+ private resolvePromise?: (userApproved: boolean) => void;
+
+ protected modalWidth = "min(500px, 90vw)";
+ protected modalHeight = "auto";
+
+ /**
+ * Request persistent storage permission.
+ * Returns true if browser granted persistent storage, false otherwise.
+ */
+ static async request(): Promise
{
+ // Check if already persisted
+ if (navigator.storage?.persisted) {
+ const alreadyPersisted = await navigator.storage.persisted();
+ if (alreadyPersisted) {
+ console.log("✓ Persistent storage already granted");
+ return true;
+ }
+ }
+
+ // Show dialog and wait for user response
+ const dialog = new PersistentStorageDialog();
+ dialog.open();
+
+ const userApproved = await new Promise((resolve) => {
+ dialog.resolvePromise = resolve;
+ });
+
+ if (!userApproved) {
+ console.warn("⚠ User declined persistent storage - sessions may be lost");
+ return false;
+ }
+
+ // User approved, request from browser
+ if (!navigator.storage?.persist) {
+ console.warn("⚠ Persistent storage API not available");
+ return false;
+ }
+
+ try {
+ const granted = await navigator.storage.persist();
+ if (granted) {
+ console.log("✓ Persistent storage granted - sessions will be preserved");
+ } else {
+ console.warn("⚠ Browser denied persistent storage - sessions may be lost under storage pressure");
+ }
+ return granted;
+ } catch (error) {
+ console.error("Failed to request persistent storage:", error);
+ return false;
+ }
+ }
+
+ private handleGrant() {
+ if (this.resolvePromise) {
+ this.resolvePromise(true);
+ this.resolvePromise = undefined;
+ }
+ this.close();
+ }
+
+ private handleDeny() {
+ if (this.resolvePromise) {
+ this.resolvePromise(false);
+ this.resolvePromise = undefined;
+ }
+ this.close();
+ }
+
+ override close() {
+ super.close();
+ if (this.resolvePromise) {
+ this.resolvePromise(false);
+ }
+ }
+
+ protected override renderContent() {
+ return html`
+ ${DialogContent({
+ children: html`
+ ${DialogHeader({
+ title: i18n("Storage Permission Required"),
+ description: i18n("This app needs persistent storage to save your conversations"),
+ })}
+
+
+
+
+
+
${i18n("Why is this needed?")}
+
+ ${i18n(
+ "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
+ )}
+
+
+
+
+
+
${i18n("What this means:")}
+
+ ${i18n("Your conversations will be saved locally in your browser")}
+ ${i18n("Data will not be deleted automatically to free up space")}
+ ${i18n("You can still manually clear data at any time")}
+ ${i18n("No data is sent to external servers")}
+
+
+
+
+
+ ${Button({
+ variant: "outline",
+ onClick: () => this.handleDeny(),
+ disabled: this.requesting,
+ children: i18n("Continue Anyway"),
+ })}
+ ${Button({
+ variant: "default",
+ onClick: () => this.handleGrant(),
+ disabled: this.requesting,
+ children: this.requesting ? i18n("Requesting...") : i18n("Grant Permission"),
+ })}
+
+ `,
+ })}
+ `;
+ }
+}
diff --git a/packages/web-ui/src/dialogs/SessionListDialog.ts b/packages/web-ui/src/dialogs/SessionListDialog.ts
new file mode 100644
index 00000000..e5126521
--- /dev/null
+++ b/packages/web-ui/src/dialogs/SessionListDialog.ts
@@ -0,0 +1,135 @@
+import { DialogBase, DialogContent, DialogHeader, html } from "@mariozechner/mini-lit";
+import { customElement, state } from "lit/decorators.js";
+import { getAppStorage } from "../storage/app-storage.js";
+import type { SessionMetadata } from "../storage/types.js";
+import { formatUsage } from "../utils/format.js";
+import { i18n } from "../utils/i18n.js";
+
+@customElement("session-list-dialog")
+export class SessionListDialog extends DialogBase {
+ @state() private sessions: SessionMetadata[] = [];
+ @state() private loading = true;
+
+ private onSelectCallback?: (sessionId: string) => void;
+
+ protected modalWidth = "min(600px, 90vw)";
+ protected modalHeight = "min(700px, 90vh)";
+
+ static async open(onSelect: (sessionId: string) => void) {
+ const dialog = new SessionListDialog();
+ dialog.onSelectCallback = onSelect;
+ dialog.open();
+ await dialog.loadSessions();
+ }
+
+ private async loadSessions() {
+ this.loading = true;
+ try {
+ const storage = getAppStorage();
+ if (!storage.sessions) {
+ console.error("Session storage not available");
+ this.sessions = [];
+ return;
+ }
+
+ this.sessions = await storage.sessions.listSessions();
+ } catch (err) {
+ console.error("Failed to load sessions:", err);
+ this.sessions = [];
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ private async handleDelete(sessionId: string, event: Event) {
+ event.stopPropagation();
+
+ if (!confirm(i18n("Delete this session?"))) {
+ return;
+ }
+
+ try {
+ const storage = getAppStorage();
+ if (!storage.sessions) return;
+
+ await storage.sessions.deleteSession(sessionId);
+ await this.loadSessions();
+ } catch (err) {
+ console.error("Failed to delete session:", err);
+ }
+ }
+
+ private handleSelect(sessionId: string) {
+ if (this.onSelectCallback) {
+ this.onSelectCallback(sessionId);
+ }
+ this.close();
+ }
+
+ private formatDate(isoString: string): string {
+ const date = new Date(isoString);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) {
+ return i18n("Today");
+ } else if (days === 1) {
+ return i18n("Yesterday");
+ } else if (days < 7) {
+ return i18n("{days} days ago").replace("{days}", days.toString());
+ } else {
+ return date.toLocaleDateString();
+ }
+ }
+
+ protected override renderContent() {
+ return html`
+ ${DialogContent({
+ className: "h-full flex flex-col",
+ children: html`
+ ${DialogHeader({
+ title: i18n("Sessions"),
+ description: i18n("Load a previous conversation"),
+ })}
+
+
+ ${
+ this.loading
+ ? html`
${i18n("Loading...")}
`
+ : this.sessions.length === 0
+ ? html`
${i18n("No sessions yet")}
`
+ : this.sessions.map(
+ (session) => html`
+
this.handleSelect(session.id)}
+ >
+
+
${session.title}
+
${this.formatDate(session.lastModified)}
+
+ ${session.messageCount} ${i18n("messages")} · ${formatUsage(session.usage)}
+
+
+
this.handleDelete(session.id, e)}
+ title=${i18n("Delete")}
+ >
+
+
+
+
+
+
+
+ `,
+ )
+ }
+
+ `,
+ })}
+ `;
+ }
+}
diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts
index 2eca6545..1793ce02 100644
--- a/packages/web-ui/src/index.ts
+++ b/packages/web-ui/src/index.ts
@@ -1,6 +1,14 @@
// Main chat interface
-export { ChatPanel } from "./ChatPanel.js";
+export type { AgentState, ThinkingLevel } from "./agent/agent.js";
+// State management
+export { Agent } from "./agent/agent.js";
+// Transports
+export { AppTransport } from "./agent/transports/AppTransport.js";
+export { ProviderTransport } from "./agent/transports/ProviderTransport.js";
+export type { ProxyAssistantMessageEvent } from "./agent/transports/proxy-types.js";
+export type { AgentRunConfig, AgentTransport } from "./agent/transports/types.js";
+export { ChatPanel } from "./ChatPanel.js";
// Components
export { AgentInterface } from "./components/AgentInterface.js";
export { AttachmentTile } from "./components/AttachmentTile.js";
@@ -9,6 +17,7 @@ export { Input } from "./components/Input.js";
export { MessageEditor } from "./components/MessageEditor.js";
export { MessageList } from "./components/MessageList.js";
// Message components
+export type { AppMessage } from "./components/Messages.js";
export { AssistantMessage, ToolMessage, UserMessage } from "./components/Messages.js";
export {
type SandboxFile,
@@ -21,24 +30,25 @@ export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js";
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
// Dialogs
export { ModelSelector } from "./dialogs/ModelSelector.js";
+export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js";
+export { SessionListDialog } from "./dialogs/SessionListDialog.js";
export { ApiKeysTab, ProxyTab, SettingsDialog, SettingsTab } from "./dialogs/SettingsDialog.js";
-export type { AgentSessionState, ThinkingLevel } from "./state/agent-session.js";
-// State management
-export { AgentSession } from "./state/agent-session.js";
-
-// Transports
-export { AppTransport } from "./state/transports/AppTransport.js";
-export { ProviderTransport } from "./state/transports/ProviderTransport.js";
-export type { ProxyAssistantMessageEvent } from "./state/transports/proxy-types.js";
-export type { AgentRunConfig, AgentTransport } from "./state/transports/types.js";
// Storage
export { AppStorage, getAppStorage, initAppStorage, setAppStorage } from "./storage/app-storage.js";
export { ChromeStorageBackend } from "./storage/backends/chrome-storage-backend.js";
export { IndexedDBBackend } from "./storage/backends/indexeddb-backend.js";
export { LocalStorageBackend } from "./storage/backends/local-storage-backend.js";
+export { SessionIndexedDBBackend } from "./storage/backends/session-indexeddb-backend.js";
export { ProviderKeysRepository } from "./storage/repositories/provider-keys-repository.js";
+export { SessionRepository } from "./storage/repositories/session-repository.js";
export { SettingsRepository } from "./storage/repositories/settings-repository.js";
-export type { AppStorageConfig, StorageBackend } from "./storage/types.js";
+export type {
+ AppStorageConfig,
+ SessionData,
+ SessionMetadata,
+ SessionStorageBackend,
+ StorageBackend,
+} from "./storage/types.js";
// Artifacts
export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js";
export { type Artifact, ArtifactsPanel, type ArtifactsParams } from "./tools/artifacts/artifacts.js";
diff --git a/packages/web-ui/src/storage/app-storage.ts b/packages/web-ui/src/storage/app-storage.ts
index da1b3ed6..69d20ed6 100644
--- a/packages/web-ui/src/storage/app-storage.ts
+++ b/packages/web-ui/src/storage/app-storage.ts
@@ -1,5 +1,6 @@
import { LocalStorageBackend } from "./backends/local-storage-backend.js";
import { ProviderKeysRepository } from "./repositories/provider-keys-repository.js";
+import { SessionRepository } from "./repositories/session-repository.js";
import { SettingsRepository } from "./repositories/settings-repository.js";
import type { AppStorageConfig } from "./types.js";
@@ -10,6 +11,7 @@ import type { AppStorageConfig } from "./types.js";
export class AppStorage {
readonly settings: SettingsRepository;
readonly providerKeys: ProviderKeysRepository;
+ readonly sessions?: SessionRepository;
constructor(config: AppStorageConfig = {}) {
// Use LocalStorage with prefixes as defaults
@@ -18,6 +20,11 @@ export class AppStorage {
this.settings = new SettingsRepository(settingsBackend);
this.providerKeys = new ProviderKeysRepository(providerKeysBackend);
+
+ // Session storage is optional
+ if (config.sessions) {
+ this.sessions = new SessionRepository(config.sessions);
+ }
}
}
diff --git a/packages/web-ui/src/storage/backends/session-indexeddb-backend.ts b/packages/web-ui/src/storage/backends/session-indexeddb-backend.ts
new file mode 100644
index 00000000..1bf029e8
--- /dev/null
+++ b/packages/web-ui/src/storage/backends/session-indexeddb-backend.ts
@@ -0,0 +1,200 @@
+import type { SessionData, SessionMetadata, SessionStorageBackend } from "../types.js";
+
+/**
+ * IndexedDB implementation of session storage.
+ * Uses two object stores:
+ * - "metadata": Fast access for listing/searching
+ * - "data": Full session data loaded on demand
+ */
+export class SessionIndexedDBBackend implements SessionStorageBackend {
+ private dbPromise: Promise | null = null;
+ private readonly DB_NAME: string;
+ private readonly DB_VERSION = 1;
+
+ constructor(dbName = "pi-sessions") {
+ this.DB_NAME = dbName;
+ }
+
+ private async getDB(): Promise {
+ if (this.dbPromise) {
+ return this.dbPromise;
+ }
+
+ this.dbPromise = new Promise((resolve, reject) => {
+ const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result);
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+
+ // Object store for metadata (lightweight, frequently accessed)
+ if (!db.objectStoreNames.contains("metadata")) {
+ const metaStore = db.createObjectStore("metadata", { keyPath: "id" });
+ // Index for sorting by last modified
+ metaStore.createIndex("lastModified", "lastModified", { unique: false });
+ }
+
+ // Object store for full session data (heavy, rarely accessed)
+ if (!db.objectStoreNames.contains("data")) {
+ db.createObjectStore("data", { keyPath: "id" });
+ }
+ };
+ });
+
+ return this.dbPromise;
+ }
+
+ async saveSession(data: SessionData, metadata: SessionMetadata): Promise {
+ const db = await this.getDB();
+
+ // Use transaction to ensure atomicity (both or neither)
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(["metadata", "data"], "readwrite");
+ const metaStore = tx.objectStore("metadata");
+ const dataStore = tx.objectStore("data");
+
+ // Save both in same transaction
+ const metaReq = metaStore.put(metadata);
+ const dataReq = dataStore.put(data);
+
+ // Handle errors
+ metaReq.onerror = () => reject(metaReq.error);
+ dataReq.onerror = () => reject(dataReq.error);
+
+ // Transaction complete = both saved
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ }
+
+ async getSession(id: string): Promise {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("data", "readonly");
+ const store = tx.objectStore("data");
+ const request = store.get(id);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ resolve(request.result !== undefined ? (request.result as SessionData) : null);
+ };
+ });
+ }
+
+ async getMetadata(id: string): Promise {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("metadata", "readonly");
+ const store = tx.objectStore("metadata");
+ const request = store.get(id);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ resolve(request.result !== undefined ? (request.result as SessionMetadata) : null);
+ };
+ });
+ }
+
+ async getAllMetadata(): Promise {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("metadata", "readonly");
+ const store = tx.objectStore("metadata");
+ const request = store.getAll();
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ resolve(request.result as SessionMetadata[]);
+ };
+ });
+ }
+
+ async deleteSession(id: string): Promise {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(["metadata", "data"], "readwrite");
+ const metaStore = tx.objectStore("metadata");
+ const dataStore = tx.objectStore("data");
+
+ // Delete both in transaction
+ const metaReq = metaStore.delete(id);
+ const dataReq = dataStore.delete(id);
+
+ metaReq.onerror = () => reject(metaReq.error);
+ dataReq.onerror = () => reject(dataReq.error);
+
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ }
+
+ async updateTitle(id: string, title: string): Promise {
+ const db = await this.getDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(["metadata", "data"], "readwrite");
+
+ // Update metadata
+ const metaStore = tx.objectStore("metadata");
+ const metaReq = metaStore.get(id);
+
+ metaReq.onsuccess = () => {
+ const metadata = metaReq.result as SessionMetadata;
+ if (!metadata) {
+ reject(new Error(`Session ${id} not found`));
+ return;
+ }
+ metadata.title = title;
+ metadata.lastModified = new Date().toISOString();
+ metaStore.put(metadata);
+ };
+
+ // Update data
+ const dataStore = tx.objectStore("data");
+ const dataReq = dataStore.get(id);
+
+ dataReq.onsuccess = () => {
+ const data = dataReq.result as SessionData;
+ if (!data) {
+ reject(new Error(`Session ${id} not found`));
+ return;
+ }
+ data.title = title;
+ data.lastModified = new Date().toISOString();
+ dataStore.put(data);
+ };
+
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+ }
+
+ async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> {
+ if (!navigator.storage || !navigator.storage.estimate) {
+ return { usage: 0, quota: 0, percent: 0 };
+ }
+
+ const estimate = await navigator.storage.estimate();
+ const usage = estimate.usage || 0;
+ const quota = estimate.quota || 0;
+ const percent = quota > 0 ? (usage / quota) * 100 : 0;
+
+ return { usage, quota, percent };
+ }
+
+ async requestPersistence(): Promise {
+ if (!navigator.storage || !navigator.storage.persist) {
+ return false;
+ }
+
+ // Check if already persistent
+ const isPersisted = await navigator.storage.persisted();
+ if (isPersisted) {
+ return true;
+ }
+
+ // Request persistence
+ return await navigator.storage.persist();
+ }
+}
diff --git a/packages/web-ui/src/storage/repositories/session-repository.ts b/packages/web-ui/src/storage/repositories/session-repository.ts
new file mode 100644
index 00000000..8923ed50
--- /dev/null
+++ b/packages/web-ui/src/storage/repositories/session-repository.ts
@@ -0,0 +1,290 @@
+import type { AgentState } from "../../agent/agent.js";
+import type { AppMessage } from "../../components/Messages.js";
+import type { SessionData, SessionMetadata, SessionStorageBackend } from "../types.js";
+
+/**
+ * Repository for managing chat sessions.
+ * Handles business logic: title generation, metadata extraction, etc.
+ */
+export class SessionRepository {
+ constructor(public backend: SessionStorageBackend) {}
+
+ /**
+ * Generate a title from the first user message.
+ * Takes first sentence or 50 chars, whichever is shorter.
+ */
+ private generateTitle(messages: AppMessage[]): string {
+ const firstUserMsg = messages.find((m) => m.role === "user");
+ if (!firstUserMsg) return "New Session";
+
+ // Extract text content
+ const content = firstUserMsg.content;
+ let text = "";
+
+ if (typeof content === "string") {
+ text = content;
+ } else {
+ const textBlocks = content.filter((c) => c.type === "text");
+ text = textBlocks.map((c) => (c as any).text || "").join(" ");
+ }
+
+ text = text.trim();
+ if (!text) return "New Session";
+
+ // Find first sentence (up to 50 chars)
+ const sentenceEnd = text.search(/[.!?]/);
+ if (sentenceEnd > 0 && sentenceEnd <= 50) {
+ return text.substring(0, sentenceEnd + 1);
+ }
+
+ // Otherwise take first 50 chars
+ return text.length <= 50 ? text : text.substring(0, 47) + "...";
+ }
+
+ /**
+ * Extract preview text from messages.
+ * Goes through all messages in sequence, extracts text content only
+ * (excludes tool calls, tool results, thinking blocks), until 2KB.
+ */
+ private extractPreview(messages: AppMessage[]): string {
+ let preview = "";
+ const MAX_SIZE = 2048; // 2KB total
+
+ for (const msg of messages) {
+ // Skip tool result messages entirely
+ if (msg.role === "toolResult") {
+ continue;
+ }
+
+ // UserMessage can have string or array content
+ if (msg.role === "user") {
+ const content = msg.content;
+
+ if (typeof content === "string") {
+ // Simple string content
+ if (preview.length + content.length <= MAX_SIZE) {
+ preview += content + " ";
+ } else {
+ preview += content.substring(0, MAX_SIZE - preview.length);
+ return preview.trim();
+ }
+ } else {
+ // Array of TextContent | ImageContent
+ const textBlocks = content.filter((c) => c.type === "text");
+ for (const block of textBlocks) {
+ const text = (block as any).text || "";
+ if (preview.length + text.length <= MAX_SIZE) {
+ preview += text + " ";
+ } else {
+ preview += text.substring(0, MAX_SIZE - preview.length);
+ return preview.trim();
+ }
+ }
+ }
+ }
+
+ // AssistantMessage has array of TextContent | ThinkingContent | ToolCall
+ if (msg.role === "assistant") {
+ // Filter to only TextContent (skip ThinkingContent and ToolCall)
+ const textBlocks = msg.content.filter((c) => c.type === "text");
+ for (const block of textBlocks) {
+ const text = (block as any).text || "";
+ if (preview.length + text.length <= MAX_SIZE) {
+ preview += text + " ";
+ } else {
+ preview += text.substring(0, MAX_SIZE - preview.length);
+ return preview.trim();
+ }
+ }
+ }
+
+ // Stop if we've hit the limit
+ if (preview.length >= MAX_SIZE) {
+ break;
+ }
+ }
+
+ return preview.trim();
+ }
+
+ /**
+ * Calculate total usage across all messages.
+ */
+ private calculateTotals(messages: AppMessage[]): {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ cost: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ total: number;
+ };
+ } {
+ let input = 0;
+ let output = 0;
+ let cacheRead = 0;
+ let cacheWrite = 0;
+ const cost = {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ total: 0,
+ };
+
+ for (const msg of messages) {
+ if (msg.role === "assistant" && (msg as any).usage) {
+ const usage = (msg as any).usage;
+ input += usage.input || 0;
+ output += usage.output || 0;
+ cacheRead += usage.cacheRead || 0;
+ cacheWrite += usage.cacheWrite || 0;
+ if (usage.cost) {
+ cost.input += usage.cost.input || 0;
+ cost.output += usage.cost.output || 0;
+ cost.cacheRead += usage.cost.cacheRead || 0;
+ cost.cacheWrite += usage.cost.cacheWrite || 0;
+ cost.total += usage.cost.total || 0;
+ }
+ }
+ }
+
+ return { input, output, cacheRead, cacheWrite, cost };
+ }
+
+ /**
+ * Extract metadata from session data.
+ */
+ private extractMetadata(data: SessionData): SessionMetadata {
+ const usage = this.calculateTotals(data.messages);
+ const preview = this.extractPreview(data.messages);
+
+ return {
+ id: data.id,
+ title: data.title,
+ createdAt: data.createdAt,
+ lastModified: data.lastModified,
+ messageCount: data.messages.length,
+ usage,
+ modelId: data.model?.id || null,
+ thinkingLevel: data.thinkingLevel,
+ preview,
+ };
+ }
+
+ /**
+ * Save session state.
+ * Extracts metadata and saves both atomically.
+ */
+ async saveSession(
+ sessionId: string,
+ state: AgentState,
+ existingCreatedAt?: string,
+ existingTitle?: string,
+ ): Promise {
+ const now = new Date().toISOString();
+
+ const data: SessionData = {
+ id: sessionId,
+ title: existingTitle || this.generateTitle(state.messages),
+ model: state.model,
+ thinkingLevel: state.thinkingLevel,
+ messages: state.messages,
+ createdAt: existingCreatedAt || now,
+ lastModified: now,
+ };
+
+ const metadata = this.extractMetadata(data);
+
+ await this.backend.saveSession(data, metadata);
+ }
+
+ /**
+ * Load full session data by ID.
+ */
+ async loadSession(id: string): Promise {
+ return this.backend.getSession(id);
+ }
+
+ /**
+ * Get all session metadata, sorted by lastModified descending.
+ */
+ async listSessions(): Promise {
+ const allMetadata = await this.backend.getAllMetadata();
+ // Sort by lastModified descending (most recent first)
+ return allMetadata.sort((a, b) => {
+ return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime();
+ });
+ }
+
+ /**
+ * Get the ID of the most recently modified session.
+ * Returns undefined if no sessions exist.
+ */
+ async getLatestSessionId(): Promise {
+ const sessions = await this.listSessions();
+ return sessions.length > 0 ? sessions[0].id : undefined;
+ }
+
+ /**
+ * Search sessions by keyword.
+ * Searches in: title and preview (first 2KB of conversation text)
+ * Returns results sorted by relevance (uses simple substring search for now).
+ */
+ async searchSessions(query: string): Promise {
+ if (!query.trim()) {
+ return this.listSessions();
+ }
+
+ const allMetadata = await this.backend.getAllMetadata();
+
+ // Simple substring search for now (can upgrade to Fuse.js later)
+ const lowerQuery = query.toLowerCase();
+ const matches = allMetadata.filter((meta) => {
+ return meta.title.toLowerCase().includes(lowerQuery) || meta.preview.toLowerCase().includes(lowerQuery);
+ });
+
+ // Sort by lastModified descending
+ return matches.sort((a, b) => {
+ return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime();
+ });
+ }
+
+ /**
+ * Get session metadata by ID.
+ */
+ async getMetadata(id: string): Promise {
+ return this.backend.getMetadata(id);
+ }
+
+ /**
+ * Delete a session.
+ */
+ async deleteSession(id: string): Promise {
+ await this.backend.deleteSession(id);
+ }
+
+ /**
+ * Update session title.
+ */
+ async updateTitle(id: string, title: string): Promise {
+ await this.backend.updateTitle(id, title);
+ }
+
+ /**
+ * Get storage quota information.
+ */
+ async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> {
+ return this.backend.getQuotaInfo();
+ }
+
+ /**
+ * Request persistent storage.
+ */
+ async requestPersistence(): Promise {
+ return this.backend.requestPersistence();
+ }
+}
diff --git a/packages/web-ui/src/storage/types.ts b/packages/web-ui/src/storage/types.ts
index d89bd628..8a54da44 100644
--- a/packages/web-ui/src/storage/types.ts
+++ b/packages/web-ui/src/storage/types.ts
@@ -1,3 +1,7 @@
+import type { Model } from "@mariozechner/pi-ai";
+import type { ThinkingLevel } from "../agent/agent.js";
+import type { AppMessage } from "../components/Messages.js";
+
/**
* Base interface for all storage backends.
* Provides a simple key-value storage abstraction that can be implemented
@@ -35,6 +39,141 @@ export interface StorageBackend {
has(key: string): Promise;
}
+/**
+ * Lightweight session metadata for listing and searching.
+ * Stored separately from full session data for performance.
+ */
+export interface SessionMetadata {
+ /** Unique session identifier (UUID v4) */
+ id: string;
+
+ /** User-defined title or auto-generated from first message */
+ title: string;
+
+ /** ISO 8601 UTC timestamp of creation */
+ createdAt: string;
+
+ /** ISO 8601 UTC timestamp of last modification */
+ lastModified: string;
+
+ /** Total number of messages (user + assistant + tool results) */
+ messageCount: number;
+
+ /** Cumulative usage statistics */
+ usage: {
+ /** Total input tokens */
+ input: number;
+ /** Total output tokens */
+ output: number;
+ /** Total cache read tokens */
+ cacheRead: number;
+ /** Total cache write tokens */
+ cacheWrite: number;
+ /** Total cost breakdown */
+ cost: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ total: number;
+ };
+ };
+
+ /** Last used model ID (e.g., "claude-sonnet-4") */
+ modelId: string | null;
+
+ /** Last used thinking level */
+ thinkingLevel: ThinkingLevel;
+
+ /**
+ * Preview text for search and display.
+ * First 2KB of conversation text (user + assistant messages in sequence).
+ * Tool calls and tool results are excluded.
+ */
+ preview: string;
+}
+
+/**
+ * Full session data including all messages.
+ * Only loaded when user opens a specific session.
+ */
+export interface SessionData {
+ /** Unique session identifier (UUID v4) */
+ id: string;
+
+ /** User-defined title or auto-generated from first message */
+ title: string;
+
+ /** Last selected model */
+ model: Model;
+
+ /** Last selected thinking level */
+ thinkingLevel: ThinkingLevel;
+
+ /** Full conversation history (with attachments inline) */
+ messages: AppMessage[];
+
+ /** ISO 8601 UTC timestamp of creation */
+ createdAt: string;
+
+ /** ISO 8601 UTC timestamp of last modification */
+ lastModified: string;
+}
+
+/**
+ * Backend interface for session storage.
+ * Implementations: IndexedDB (browser/extension), VSCode global state, etc.
+ */
+export interface SessionStorageBackend {
+ /**
+ * Save both session data and metadata atomically.
+ * Should use transactions to ensure consistency.
+ */
+ saveSession(data: SessionData, metadata: SessionMetadata): Promise;
+
+ /**
+ * Get full session data by ID.
+ * Returns null if session doesn't exist.
+ */
+ getSession(id: string): Promise;
+
+ /**
+ * Get session metadata by ID.
+ * Returns null if session doesn't exist.
+ */
+ getMetadata(id: string): Promise;
+
+ /**
+ * Get all session metadata (for listing/searching).
+ * Should be efficient - metadata is small (~2KB each).
+ */
+ getAllMetadata(): Promise;
+
+ /**
+ * Delete a session (both data and metadata).
+ * Should use transactions to ensure both are deleted.
+ */
+ deleteSession(id: string): Promise;
+
+ /**
+ * Update session title (in both data and metadata).
+ * Optimized operation - no need to save full session.
+ */
+ updateTitle(id: string, title: string): Promise;
+
+ /**
+ * Get storage quota information.
+ * Used for warning users when approaching limits.
+ */
+ getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }>;
+
+ /**
+ * Request persistent storage (prevents eviction).
+ * Returns true if granted, false otherwise.
+ */
+ requestPersistence(): Promise;
+}
+
/**
* Options for configuring AppStorage.
*/
@@ -43,6 +182,6 @@ export interface AppStorageConfig {
settings?: StorageBackend;
/** Backend for provider API keys */
providerKeys?: StorageBackend;
- /** Backend for sessions (chat history, attachments) */
- sessions?: StorageBackend;
+ /** Backend for sessions (optional - can be undefined if persistence not needed) */
+ sessions?: SessionStorageBackend;
}
diff --git a/packages/web-ui/src/utils/i18n.ts b/packages/web-ui/src/utils/i18n.ts
index 9cc2e56c..279efc0d 100644
--- a/packages/web-ui/src/utils/i18n.ts
+++ b/packages/web-ui/src/utils/i18n.ts
@@ -113,6 +113,28 @@ declare module "@mariozechner/mini-lit" {
Low: string;
Medium: string;
High: string;
+ "Storage Permission Required": string;
+ "This app needs persistent storage to save your conversations": string;
+ "Why is this needed?": string;
+ "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.": string;
+ "What this means:": string;
+ "Your conversations will be saved locally in your browser": string;
+ "Data will not be deleted automatically to free up space": string;
+ "You can still manually clear data at any time": string;
+ "No data is sent to external servers": string;
+ "Continue Anyway": string;
+ "Requesting...": string;
+ "Grant Permission": string;
+ Sessions: string;
+ "Load a previous conversation": string;
+ "No sessions yet": string;
+ "Delete this session?": string;
+ Today: string;
+ Yesterday: string;
+ "{days} days ago": string;
+ messages: string;
+ tokens: string;
+ "Drop files here": string;
}
}
@@ -203,7 +225,6 @@ const translations = {
Create: "Create",
Rewrite: "Rewrite",
Get: "Get",
- Delete: "Delete",
"Get logs": "Get logs",
"Show artifacts": "Show artifacts",
"Close artifacts": "Close artifacts",
@@ -233,6 +254,33 @@ const translations = {
Low: "Low",
Medium: "Medium",
High: "High",
+ "Storage Permission Required": "Storage Permission Required",
+ "This app needs persistent storage to save your conversations":
+ "This app needs persistent storage to save your conversations",
+ "Why is this needed?": "Why is this needed?",
+ "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.":
+ "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
+ "What this means:": "What this means:",
+ "Your conversations will be saved locally in your browser":
+ "Your conversations will be saved locally in your browser",
+ "Data will not be deleted automatically to free up space":
+ "Data will not be deleted automatically to free up space",
+ "You can still manually clear data at any time": "You can still manually clear data at any time",
+ "No data is sent to external servers": "No data is sent to external servers",
+ "Continue Anyway": "Continue Anyway",
+ "Requesting...": "Requesting...",
+ "Grant Permission": "Grant Permission",
+ Sessions: "Sessions",
+ "Load a previous conversation": "Load a previous conversation",
+ "No sessions yet": "No sessions yet",
+ "Delete this session?": "Delete this session?",
+ Today: "Today",
+ Yesterday: "Yesterday",
+ "{days} days ago": "{days} days ago",
+ messages: "messages",
+ tokens: "tokens",
+ Delete: "Delete",
+ "Drop files here": "Drop files here",
},
de: {
...defaultGerman,
@@ -320,7 +368,6 @@ const translations = {
Create: "Erstellen",
Rewrite: "Überschreiben",
Get: "Abrufen",
- Delete: "Löschen",
"Get logs": "Logs abrufen",
"Show artifacts": "Artefakte anzeigen",
"Close artifacts": "Artefakte schließen",
@@ -350,6 +397,33 @@ const translations = {
Low: "Niedrig",
Medium: "Mittel",
High: "Hoch",
+ "Storage Permission Required": "Speicherberechtigung erforderlich",
+ "This app needs persistent storage to save your conversations":
+ "Diese App benötigt dauerhaften Speicher, um Ihre Konversationen zu speichern",
+ "Why is this needed?": "Warum wird das benötigt?",
+ "Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.":
+ "Ohne dauerhaften Speicher kann Ihr Browser gespeicherte Konversationen löschen, wenn Speicherplatz benötigt wird. Diese Berechtigung stellt sicher, dass Ihr Chatverlauf erhalten bleibt.",
+ "What this means:": "Was das bedeutet:",
+ "Your conversations will be saved locally in your browser":
+ "Ihre Konversationen werden lokal in Ihrem Browser gespeichert",
+ "Data will not be deleted automatically to free up space":
+ "Daten werden nicht automatisch gelöscht, um Speicherplatz freizugeben",
+ "You can still manually clear data at any time": "Sie können Daten jederzeit manuell löschen",
+ "No data is sent to external servers": "Keine Daten werden an externe Server gesendet",
+ "Continue Anyway": "Trotzdem fortfahren",
+ "Requesting...": "Anfrage läuft...",
+ "Grant Permission": "Berechtigung erteilen",
+ Sessions: "Sitzungen",
+ "Load a previous conversation": "Frühere Konversation laden",
+ "No sessions yet": "Noch keine Sitzungen",
+ "Delete this session?": "Diese Sitzung löschen?",
+ Today: "Heute",
+ Yesterday: "Gestern",
+ "{days} days ago": "vor {days} Tagen",
+ messages: "Nachrichten",
+ tokens: "Tokens",
+ Delete: "Löschen",
+ "Drop files here": "Dateien hier ablegen",
},
};