| Philip Zeyliger | e08c7ff | 2025-06-06 13:22:12 -0700 | [diff] [blame] | 1 | import { css, html, LitElement } from "lit"; |
| 2 | import { customElement, property, state } from "lit/decorators.js"; |
| 3 | import { AgentMessage } from "../types"; |
| 4 | import { createRef, ref } from "lit/directives/ref.js"; |
| 5 | |
| 6 | @customElement("mobile-chat") |
| 7 | export class MobileChat extends LitElement { |
| 8 | @property({ type: Array }) |
| 9 | messages: AgentMessage[] = []; |
| 10 | |
| 11 | @property({ type: Boolean }) |
| 12 | isThinking = false; |
| 13 | |
| 14 | private scrollContainer = createRef<HTMLDivElement>(); |
| 15 | |
| 16 | static styles = css` |
| 17 | :host { |
| 18 | display: block; |
| 19 | height: 100%; |
| 20 | overflow: hidden; |
| 21 | } |
| 22 | |
| 23 | .chat-container { |
| 24 | height: 100%; |
| 25 | overflow-y: auto; |
| 26 | padding: 16px; |
| 27 | display: flex; |
| 28 | flex-direction: column; |
| 29 | gap: 16px; |
| 30 | scroll-behavior: smooth; |
| 31 | -webkit-overflow-scrolling: touch; |
| 32 | } |
| 33 | |
| 34 | .message { |
| 35 | display: flex; |
| 36 | flex-direction: column; |
| 37 | max-width: 85%; |
| 38 | word-wrap: break-word; |
| 39 | } |
| 40 | |
| 41 | .message.user { |
| 42 | align-self: flex-end; |
| 43 | align-items: flex-end; |
| 44 | } |
| 45 | |
| 46 | .message.assistant { |
| 47 | align-self: flex-start; |
| 48 | align-items: flex-start; |
| 49 | } |
| 50 | |
| 51 | .message-bubble { |
| 52 | padding: 8px 12px; |
| 53 | border-radius: 18px; |
| 54 | font-size: 16px; |
| 55 | line-height: 1.4; |
| 56 | } |
| 57 | |
| 58 | .message.user .message-bubble { |
| 59 | background-color: #007bff; |
| 60 | color: white; |
| 61 | border-bottom-right-radius: 6px; |
| 62 | } |
| 63 | |
| 64 | .message.assistant .message-bubble { |
| 65 | background-color: #f1f3f4; |
| 66 | color: #333; |
| 67 | border-bottom-left-radius: 6px; |
| 68 | } |
| 69 | |
| 70 | .thinking-message { |
| 71 | align-self: flex-start; |
| 72 | align-items: flex-start; |
| 73 | max-width: 85%; |
| 74 | } |
| 75 | |
| 76 | .thinking-bubble { |
| 77 | background-color: #f1f3f4; |
| 78 | padding: 16px; |
| 79 | border-radius: 18px; |
| 80 | border-bottom-left-radius: 6px; |
| 81 | display: flex; |
| 82 | align-items: center; |
| 83 | gap: 8px; |
| 84 | } |
| 85 | |
| 86 | .thinking-text { |
| 87 | color: #6c757d; |
| 88 | font-style: italic; |
| 89 | } |
| 90 | |
| 91 | .thinking-dots { |
| 92 | display: flex; |
| 93 | gap: 3px; |
| 94 | } |
| 95 | |
| 96 | .thinking-dot { |
| 97 | width: 6px; |
| 98 | height: 6px; |
| 99 | border-radius: 50%; |
| 100 | background-color: #6c757d; |
| 101 | animation: thinking 1.4s ease-in-out infinite both; |
| 102 | } |
| 103 | |
| 104 | .thinking-dot:nth-child(1) { |
| 105 | animation-delay: -0.32s; |
| 106 | } |
| 107 | .thinking-dot:nth-child(2) { |
| 108 | animation-delay: -0.16s; |
| 109 | } |
| 110 | .thinking-dot:nth-child(3) { |
| 111 | animation-delay: 0; |
| 112 | } |
| 113 | |
| 114 | @keyframes thinking { |
| 115 | 0%, |
| 116 | 80%, |
| 117 | 100% { |
| 118 | transform: scale(0.8); |
| 119 | opacity: 0.5; |
| 120 | } |
| 121 | 40% { |
| 122 | transform: scale(1); |
| 123 | opacity: 1; |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | .empty-state { |
| 128 | flex: 1; |
| 129 | display: flex; |
| 130 | align-items: center; |
| 131 | justify-content: center; |
| 132 | color: #6c757d; |
| 133 | font-style: italic; |
| 134 | text-align: center; |
| 135 | padding: 32px; |
| 136 | } |
| 137 | `; |
| 138 | |
| 139 | updated(changedProperties: Map<string, any>) { |
| 140 | super.updated(changedProperties); |
| 141 | if ( |
| 142 | changedProperties.has("messages") || |
| 143 | changedProperties.has("isThinking") |
| 144 | ) { |
| 145 | this.scrollToBottom(); |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | private scrollToBottom() { |
| 150 | // Use requestAnimationFrame to ensure DOM is updated |
| 151 | requestAnimationFrame(() => { |
| 152 | if (this.scrollContainer.value) { |
| 153 | this.scrollContainer.value.scrollTop = |
| 154 | this.scrollContainer.value.scrollHeight; |
| 155 | } |
| 156 | }); |
| 157 | } |
| 158 | |
| 159 | private formatTime(timestamp: string): string { |
| 160 | const date = new Date(timestamp); |
| 161 | return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); |
| 162 | } |
| 163 | |
| 164 | private getMessageRole(message: AgentMessage): string { |
| 165 | if (message.type === "user") { |
| 166 | return "user"; |
| 167 | } |
| 168 | return "assistant"; |
| 169 | } |
| 170 | |
| 171 | private getMessageText(message: AgentMessage): string { |
| 172 | return message.content || ""; |
| 173 | } |
| 174 | |
| 175 | private shouldShowMessage(message: AgentMessage): boolean { |
| 176 | // Show user, agent, and error messages with content |
| 177 | return ( |
| 178 | (message.type === "user" || |
| 179 | message.type === "agent" || |
| 180 | message.type === "error") && |
| 181 | message.content && |
| 182 | message.content.trim().length > 0 |
| 183 | ); |
| 184 | } |
| 185 | |
| 186 | render() { |
| 187 | const displayMessages = this.messages.filter((msg) => |
| 188 | this.shouldShowMessage(msg), |
| 189 | ); |
| 190 | |
| 191 | return html` |
| 192 | <div class="chat-container" ${ref(this.scrollContainer)}> |
| 193 | ${displayMessages.length === 0 |
| 194 | ? html` |
| 195 | <div class="empty-state">Start a conversation with Sketch...</div> |
| 196 | ` |
| 197 | : displayMessages.map((message) => { |
| 198 | const role = this.getMessageRole(message); |
| 199 | const text = this.getMessageText(message); |
| 200 | const timestamp = message.timestamp; |
| 201 | |
| 202 | return html` |
| 203 | <div class="message ${role}"> |
| 204 | <div class="message-bubble">${text}</div> |
| 205 | </div> |
| 206 | `; |
| 207 | })} |
| 208 | ${this.isThinking |
| 209 | ? html` |
| 210 | <div class="thinking-message"> |
| 211 | <div class="thinking-bubble"> |
| 212 | <span class="thinking-text">Sketch is thinking</span> |
| 213 | <div class="thinking-dots"> |
| 214 | <div class="thinking-dot"></div> |
| 215 | <div class="thinking-dot"></div> |
| 216 | <div class="thinking-dot"></div> |
| 217 | </div> |
| 218 | </div> |
| 219 | </div> |
| 220 | ` |
| 221 | : ""} |
| 222 | </div> |
| 223 | `; |
| 224 | } |
| 225 | } |