co-mono/packages/web-ui/src/ChatPanel.ts
Mario Zechner c9be21ebad Add navigation message tracking to browser extension
- Add onBeforeSend callback to ChatPanel and AgentInterface
- Add onBeforeToolCall callback (for future permission dialogs)
- Create NavigationMessage custom message type
- Add browserMessageTransformer that converts nav messages to <system> tags
- Track last submitted URL and tab index
- Auto-insert navigation message when URL/tab changes on user submit
- Navigation message shows favicon + page title in UI
- LLM receives: <system>Navigated to [title] (tab X): [url]</system>
2025-10-06 13:49:28 +02:00

197 lines
6.5 KiB
TypeScript

import { Badge, html } from "@mariozechner/mini-lit";
import { LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { AgentInterface } from "./components/AgentInterface.js";
import "./components/AgentInterface.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 { 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 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({ attribute: false }) sandboxUrlProvider?: () => string;
@property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
@property({ attribute: false }) onBeforeSend?: () => void | Promise<void>;
@property({ attribute: false }) additionalTools?: any[];
private resizeHandler = () => {
this.windowWidth = window.innerWidth;
this.requestUpdate();
};
createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
window.addEventListener("resize", this.resizeHandler);
this.style.display = "flex";
this.style.flexDirection = "column";
this.style.height = "100%";
this.style.minHeight = "0";
}
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;
this.agentInterface.onBeforeSend = this.onBeforeSend;
// Create JavaScript REPL tool
const javascriptReplTool = createJavaScriptReplTool();
if (this.sandboxUrlProvider) {
javascriptReplTool.sandboxUrlProvider = this.sandboxUrlProvider;
}
// Set up artifacts panel
this.artifactsPanel = new ArtifactsPanel();
if (this.sandboxUrlProvider) {
this.artifactsPanel.sandboxUrlProvider = this.sandboxUrlProvider;
}
registerToolRenderer("artifacts", this.artifactsPanel);
// Attachments provider
const getAttachments = () => {
const attachments: any[] = [];
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) {
if (typeof block !== "string" && block.type === "image") {
attachments.push({
id: `image-${attachments.length}`,
fileName: "image.png",
mimeType: block.mimeType || "image/png",
size: 0,
content: block.data,
});
}
}
}
}
return attachments;
};
javascriptReplTool.attachmentsProvider = getAttachments;
this.artifactsPanel.attachmentsProvider = getAttachments;
this.artifactsPanel.onArtifactsChange = () => {
const count = this.artifactsPanel?.artifacts?.size ?? 0;
const created = count > this.artifactCount;
this.hasArtifacts = count > 0;
this.artifactCount = count;
if (this.hasArtifacts && created) {
this.showArtifactsPanel = true;
}
this.requestUpdate();
};
this.artifactsPanel.onClose = () => {
this.showArtifactsPanel = false;
this.requestUpdate();
};
this.artifactsPanel.onOpen = () => {
this.showArtifactsPanel = true;
this.requestUpdate();
};
// Set tools on the agent
const tools = [javascriptReplTool, this.artifactsPanel.tool, ...(this.additionalTools || [])];
this.agent.setTools(tools);
// 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;
this.hasArtifacts = this.artifactsPanel.artifacts.size > 0;
this.artifactCount = this.artifactsPanel.artifacts.size;
this.requestUpdate();
}
render() {
if (!this.agent || !this.agentInterface) {
return html`<div class="flex items-center justify-center h-full">
<div class="text-muted-foreground">No agent set</div>
</div>`;
}
const isMobile = this.windowWidth < BREAKPOINT;
// Set panel props
if (this.artifactsPanel) {
this.artifactsPanel.collapsed = !this.showArtifactsPanel;
this.artifactsPanel.overlay = isMobile;
}
return html`
<div class="relative w-full h-full overflow-hidden flex">
<div class="h-full" style="${!isMobile && this.showArtifactsPanel && this.hasArtifacts ? "width: 50%;" : "width: 100%;"}">
${this.agentInterface}
</div>
<!-- Floating pill when artifacts exist and panel is collapsed -->
${
this.hasArtifacts && !this.showArtifactsPanel
? html`
<button
class="absolute z-30 top-4 left-1/2 -translate-x-1/2 pointer-events-auto"
@click=${() => {
this.showArtifactsPanel = true;
this.requestUpdate();
}}
title=${i18n("Show artifacts")}
>
${Badge(html`
<span class="inline-flex items-center gap-1">
<span>${i18n("Artifacts")}</span>
${
this.artifactCount > 1
? html`<span
class="text-[10px] leading-none bg-primary-foreground/20 text-primary-foreground rounded px-1 font-mono tabular-nums"
>${this.artifactCount}</span
>`
: ""
}
</span>
`)}
</button>
`
: ""
}
<div class="h-full ${isMobile ? "absolute inset-0 pointer-events-none" : ""}" style="${!isMobile ? (!this.hasArtifacts || !this.showArtifactsPanel ? "display: none;" : "width: 50%;") : ""}">
${this.artifactsPanel}
</div>
</div>
`;
}
}