blob: bd3a9e3a7104fbcf7083e334292427dbd95ded41 [file] [log] [blame]
/* eslint-disable @typescript-eslint/no-explicit-any */
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { AgentMessage } from "../types";
import { createRef, ref } from "lit/directives/ref.js";
import { marked, MarkedOptions, Renderer } from "marked";
import DOMPurify from "dompurify";
@customElement("mobile-chat")
export class MobileChat extends LitElement {
@property({ type: Array })
messages: AgentMessage[] = [];
@property({ type: Boolean })
isThinking = false;
private scrollContainer = createRef<HTMLDivElement>();
@state()
private showJumpToBottom = false;
static styles = css`
:host {
display: block;
height: 100%;
overflow: hidden;
}
.chat-container {
height: 100%;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.message {
display: flex;
flex-direction: column;
max-width: 85%;
word-wrap: break-word;
}
.message.user {
align-self: flex-end;
align-items: flex-end;
}
.message.assistant {
align-self: flex-start;
align-items: flex-start;
}
.message-bubble {
padding: 8px 12px;
border-radius: 18px;
font-size: 16px;
line-height: 1.4;
}
.message.user .message-bubble {
background-color: #007bff;
color: white;
border-bottom-right-radius: 6px;
}
.message.assistant .message-bubble {
background-color: #f1f3f4;
color: #333;
border-bottom-left-radius: 6px;
}
.message.error .message-bubble {
background-color: #ffebee;
color: #d32f2f;
border-radius: 18px;
}
.thinking-message {
align-self: flex-start;
align-items: flex-start;
max-width: 85%;
}
.thinking-bubble {
background-color: #f1f3f4;
padding: 16px;
border-radius: 18px;
border-bottom-left-radius: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.thinking-text {
color: #6c757d;
font-style: italic;
}
.thinking-dots {
display: flex;
gap: 3px;
}
.thinking-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #6c757d;
animation: thinking 1.4s ease-in-out infinite both;
}
.thinking-dot:nth-child(1) {
animation-delay: -0.32s;
}
.thinking-dot:nth-child(2) {
animation-delay: -0.16s;
}
.thinking-dot:nth-child(3) {
animation-delay: 0;
}
@keyframes thinking {
0%,
80%,
100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #6c757d;
font-style: italic;
text-align: center;
padding: 32px;
}
/* Markdown content styling for mobile */
.markdown-content {
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;
}
.markdown-content p {
margin: 0.3em 0;
}
.markdown-content p:first-child {
margin-top: 0;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin: 0.5em 0 0.3em 0;
font-weight: bold;
}
.markdown-content h1 {
font-size: 1.2em;
}
.markdown-content h2 {
font-size: 1.15em;
}
.markdown-content h3 {
font-size: 1.1em;
}
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
font-size: 1.05em;
}
.markdown-content code {
background-color: rgba(0, 0, 0, 0.08);
padding: 2px 4px;
border-radius: 3px;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.9em;
}
.markdown-content pre {
background-color: rgba(0, 0, 0, 0.08);
padding: 8px;
border-radius: 6px;
margin: 0.5em 0;
overflow-x: auto;
font-size: 0.9em;
}
.markdown-content pre code {
background: none;
padding: 0;
}
.markdown-content ul,
.markdown-content ol {
margin: 0.5em 0;
padding-left: 1.2em;
}
.markdown-content li {
margin: 0.2em 0;
}
.markdown-content blockquote {
border-left: 3px solid rgba(0, 0, 0, 0.2);
margin: 0.5em 0;
padding-left: 0.8em;
font-style: italic;
}
.markdown-content a {
color: inherit;
text-decoration: underline;
}
.markdown-content strong,
.markdown-content b {
font-weight: bold;
}
.markdown-content em,
.markdown-content i {
font-style: italic;
}
/* Tool calls styling for mobile */
.tool-calls {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.tool-call-item {
background-color: rgba(0, 0, 0, 0.04);
border-radius: 8px;
padding: 6px 8px;
font-size: 12px;
font-family: monospace;
line-height: 1.3;
display: flex;
align-items: center;
gap: 6px;
}
.tool-status-icon {
flex-shrink: 0;
font-size: 14px;
}
.tool-name {
font-weight: bold;
color: #333;
flex-shrink: 0;
margin-right: 2px;
}
.tool-summary {
color: #555;
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-duration {
font-size: 10px;
color: #888;
flex-shrink: 0;
margin-left: 4px;
}
.jump-to-bottom {
position: fixed;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.6);
color: white;
border: none;
border-radius: 12px;
padding: 4px 8px;
font-size: 11px;
font-weight: 400;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
z-index: 1100;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 4px;
opacity: 0.8;
}
.jump-to-bottom:hover {
background-color: rgba(0, 0, 0, 0.8);
transform: translateX(-50%) translateY(-1px);
opacity: 1;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.jump-to-bottom:active {
transform: translateX(-50%) translateY(0);
}
.jump-to-bottom.hidden {
opacity: 0;
pointer-events: none;
transform: translateX(-50%) translateY(10px);
}
`;
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (
changedProperties.has("messages") ||
changedProperties.has("isThinking")
) {
this.scrollToBottom();
}
// Set up scroll listener if not already done
if (this.scrollContainer.value && !this.scrollContainer.value.onscroll) {
this.scrollContainer.value.addEventListener(
"scroll",
this.handleScroll.bind(this),
);
}
}
private scrollToBottom() {
// Use requestAnimationFrame to ensure DOM is updated
requestAnimationFrame(() => {
if (this.scrollContainer.value) {
this.scrollContainer.value.scrollTop =
this.scrollContainer.value.scrollHeight;
}
});
}
private handleScroll() {
if (!this.scrollContainer.value) return;
const container = this.scrollContainer.value;
const isAtBottom =
container.scrollTop + container.clientHeight >=
container.scrollHeight - 50; // 50px tolerance
this.showJumpToBottom = !isAtBottom;
}
private jumpToBottom() {
this.scrollToBottom();
}
private formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
private getMessageRole(message: AgentMessage): string {
if (message.type === "user") {
return "user";
}
if (message.type === "error") {
return "error";
}
return "assistant";
}
private getMessageText(message: AgentMessage): string {
return message.content || "";
}
private shouldShowMessage(message: AgentMessage): boolean {
// Filter out hidden messages (subconversations) like the regular UI does
if (message.hide_output) {
return false;
}
// Show user, agent, and error messages with content
return (
(message.type === "user" ||
message.type === "agent" ||
message.type === "error") &&
message.content &&
message.content.trim().length > 0
);
}
private renderMarkdown(markdownContent: string): string {
try {
// Create a custom renderer for mobile-optimized rendering
const renderer = new Renderer();
// Override code renderer to simplify for mobile
renderer.code = function ({
text,
lang,
}: {
text: string;
lang?: string;
}): string {
const langClass = lang ? ` class="language-${lang}"` : "";
return `<pre><code${langClass}>${text}</code></pre>`;
};
// Set markdown options for mobile
const markedOptions: MarkedOptions = {
gfm: true, // GitHub Flavored Markdown
breaks: true, // Convert newlines to <br>
async: false,
renderer: renderer,
};
// Parse markdown and sanitize the output HTML
const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
return DOMPurify.sanitize(htmlOutput, {
ALLOWED_TAGS: [
"p",
"br",
"strong",
"em",
"b",
"i",
"u",
"s",
"code",
"pre",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"blockquote",
"a",
],
ALLOWED_ATTR: ["href", "title", "target", "rel", "class"],
KEEP_CONTENT: true,
});
} catch (error) {
console.error("Error rendering markdown:", error);
// Fallback to sanitized plain text if markdown parsing fails
return DOMPurify.sanitize(markdownContent);
}
}
private renderToolCalls(message: AgentMessage) {
if (!message.tool_calls || message.tool_calls.length === 0) {
return "";
}
return html`
<div class="tool-calls">
${message.tool_calls.map((toolCall) => {
const summary = this.getToolSummary(toolCall);
return html`
<div class="tool-call-item ${toolCall.name}">
<span class="tool-name">${toolCall.name}</span>
<span class="tool-summary">${summary}</span>
</div>
`;
})}
</div>
`;
}
private getToolStatusIcon(_toolCall: any): string {
// Don't show status icons for mobile
return "";
}
private getToolSummary(toolCall: any): string {
try {
const input = JSON.parse(toolCall.input || "{}");
/* eslint-disable no-case-declarations */
switch (toolCall.name) {
case "bash":
const command = input.command || "";
const isBackground = input.background === true;
const bgPrefix = isBackground ? "[bg] " : "";
return (
bgPrefix +
(command.length > 40 ? command.substring(0, 40) + "..." : command)
);
case "patch":
const path = input.path || "unknown";
const patchCount = (input.patches || []).length;
return `${path}: ${patchCount} edit${patchCount > 1 ? "s" : ""}`;
case "think":
const thoughts = input.thoughts || "";
const firstLine = thoughts.split("\n")[0] || "";
return firstLine.length > 50
? firstLine.substring(0, 50) + "..."
: firstLine;
case "keyword_search":
const query = input.query || "";
return query.length > 50 ? query.substring(0, 50) + "..." : query;
case "browser_navigate":
return input.url || "";
case "browser_take_screenshot":
return "Taking screenshot";
case "browser_click":
return `Click: ${input.selector || ""}`;
case "browser_type":
const text = input.text || "";
return `Type: ${text.length > 30 ? text.substring(0, 30) + "..." : text}`;
case "todo_write":
const tasks = input.tasks || [];
return `${tasks.length} task${tasks.length > 1 ? "s" : ""}`;
case "todo_read":
return "Read todo list";
case "set-slug":
return `Slug: "${input.slug || ""}"`;
case "multiplechoice":
const question = input.question || "Multiple choice question";
const options = input.responseOptions || [];
if (options.length > 0) {
const optionsList = options.map((opt) => opt.caption).join(", ");
return `${question} [${optionsList}]`;
}
return question;
case "done":
return "Task completion checklist";
default:
// For unknown tools, show first part of input
const inputStr = JSON.stringify(input);
return inputStr.length > 50
? inputStr.substring(0, 50) + "..."
: inputStr;
}
/* eslint-enable no-case-declarations */
} catch {
return "Tool call";
}
}
private getToolDuration(_toolCall: any): string {
// Don't show duration for mobile
return "";
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.scrollContainer.value) {
this.scrollContainer.value.removeEventListener(
"scroll",
this.handleScroll.bind(this),
);
}
}
render() {
const displayMessages = this.messages.filter((msg) =>
this.shouldShowMessage(msg),
);
return html`
<div class="chat-container" ${ref(this.scrollContainer)}>
${displayMessages.length === 0
? html`
<div class="empty-state">Start a conversation with Sketch...</div>
`
: displayMessages.map((message) => {
const role = this.getMessageRole(message);
const text = this.getMessageText(message);
// const timestamp = message.timestamp; // Unused for mobile layout
return html`
<div class="message ${role}">
<div class="message-bubble">
${role === "assistant"
? html`<div class="markdown-content">
${unsafeHTML(this.renderMarkdown(text))}
</div>`
: text}
${this.renderToolCalls(message)}
</div>
</div>
`;
})}
${this.isThinking
? html`
<div class="thinking-message">
<div class="thinking-bubble">
<span class="thinking-text">Sketch is thinking</span>
<div class="thinking-dots">
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
</div>
</div>
</div>
`
: ""}
</div>
${this.showJumpToBottom
? html`
<button
class="jump-to-bottom"
@click=${this.jumpToBottom}
aria-label="Jump to bottom"
>
↓ Jump to bottom
</button>
`
: ""}
`;
}
}