webui: migrate mobile components to SketchTailwindElement
Complete migration of all mobile web components from LitElement to
SketchTailwindElement base class with Tailwind CSS styling:
Components migrated:
- mobile-chat-input.ts: Chat input with file upload, textarea auto-resize
- mobile-chat.ts: Message display with markdown rendering and tool calls
- mobile-diff.ts: Git diff viewer with Monaco editor integration
- mobile-shell.ts: Main container coordinating mobile UI layout
- mobile-title.ts: Header with connection status and view switching
Key changes:
- Replaced LitElement inheritance with SketchTailwindElement
- Converted all CSS-in-JS styles to Tailwind utility classes
- Removed static styles blocks and shadow DOM styling
- Added custom animations via document.head for non-Tailwind effects
- Preserved all existing functionality and component interactions
Technical improvements:
- Consistent iOS safe area support with env() CSS functions
- Proper flexbox layouts for mobile responsive design
- Maintained accessibility with proper ARIA labels and focus states
- Enhanced hover and active states using Tailwind modifiers
- Optimized touch interactions with -webkit-overflow-scrolling
The mobile components now follow the established SketchTailwindElement
pattern while maintaining full feature parity with the original
shadow DOM implementations.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s21f840091392b02ek
diff --git a/webui/src/web-components/mobile-chat.ts b/webui/src/web-components/mobile-chat.ts
index bd3a9e3..9ce01fe 100644
--- a/webui/src/web-components/mobile-chat.ts
+++ b/webui/src/web-components/mobile-chat.ts
@@ -1,14 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { css, html, LitElement } from "lit";
+import { html } 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";
+import { SketchTailwindElement } from "./sketch-tailwind-element";
@customElement("mobile-chat")
-export class MobileChat extends LitElement {
+export class MobileChat extends SketchTailwindElement {
@property({ type: Array })
messages: AgentMessage[] = [];
@@ -20,318 +21,33 @@
@state()
private showJumpToBottom = false;
- static styles = css`
- :host {
- display: block;
- height: 100%;
- overflow: hidden;
+ connectedCallback() {
+ super.connectedCallback();
+ // Add animation styles to document head if not already present
+ if (!document.getElementById("mobile-chat-animations")) {
+ const style = document.createElement("style");
+ style.id = "mobile-chat-animations";
+ style.textContent = `
+ @keyframes thinking {
+ 0%, 80%, 100% {
+ transform: scale(0.8);
+ opacity: 0.5;
+ }
+ 40% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+ .thinking-dot {
+ 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; }
+ `;
+ document.head.appendChild(style);
}
-
- .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);
@@ -478,14 +194,21 @@
}
return html`
- <div class="tool-calls">
+ <div class="mt-3 flex flex-col gap-1.5">
${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
+ class="bg-black/[0.04] rounded-lg px-2 py-1.5 text-xs font-mono leading-snug flex items-center gap-1.5 ${toolCall.name}"
+ >
+ <span class="font-bold text-gray-800 flex-shrink-0 mr-0.5"
+ >${toolCall.name}</span
+ >
+ <span
+ class="text-gray-600 flex-grow overflow-hidden text-ellipsis whitespace-nowrap"
+ >${summary}</span
+ >
</div>
`;
})}
@@ -598,49 +321,165 @@
);
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
+ <div class="block h-full overflow-hidden">
+ <div
+ class="h-full overflow-y-auto p-4 flex flex-col gap-4 scroll-smooth"
+ style="-webkit-overflow-scrolling: touch;"
+ ${ref(this.scrollContainer)}
+ >
+ ${displayMessages.length === 0
+ ? html`
+ <div
+ class="empty-state flex-1 flex items-center justify-center text-gray-500 italic text-center p-8"
+ >
+ 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)}
+ return html`
+ <div
+ class="message ${role} flex flex-col max-w-[85%] break-words ${role ===
+ "user"
+ ? "self-end items-end"
+ : "self-start items-start"}"
+ >
+ <div
+ class="message-bubble px-3 py-2 rounded-[18px] text-base leading-relaxed ${role ===
+ "user"
+ ? "bg-blue-500 text-white rounded-br-[6px]"
+ : role === "error"
+ ? "bg-red-50 text-red-700"
+ : "bg-gray-100 text-gray-800 rounded-bl-[6px]"}"
+ >
+ ${role === "assistant"
+ ? html`<div class="leading-6 break-words">
+ <style>
+ .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;
+ }
+ </style>
+ <div class="markdown-content">
+ ${unsafeHTML(this.renderMarkdown(text))}
+ </div>
+ </div>`
+ : text}
+ ${this.renderToolCalls(message)}
+ </div>
+ </div>
+ `;
+ })}
+ ${this.isThinking
+ ? html`
+ <div
+ class="thinking-message flex flex-col max-w-[85%] break-words self-start items-start"
+ >
+ <div
+ class="bg-gray-100 p-4 rounded-[18px] rounded-bl-[6px] flex items-center gap-2"
+ >
+ <span class="thinking-text text-gray-500 italic"
+ >Sketch is thinking</span
+ >
+ <div class="thinking-dots flex gap-1">
+ <div
+ class="w-1.5 h-1.5 rounded-full bg-gray-500 thinking-dot"
+ ></div>
+ <div
+ class="w-1.5 h-1.5 rounded-full bg-gray-500 thinking-dot"
+ ></div>
+ <div
+ class="w-1.5 h-1.5 rounded-full bg-gray-500 thinking-dot"
+ ></div>
+ </div>
</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>
</div>
${this.showJumpToBottom
? html`
<button
- class="jump-to-bottom"
+ class="fixed bottom-[70px] left-1/2 transform -translate-x-1/2 bg-black/60 text-white border-none rounded-xl px-2 py-1 text-xs font-normal cursor-pointer shadow-sm z-[1100] transition-all duration-150 flex items-center gap-1 opacity-80 hover:bg-black/80 hover:-translate-y-px hover:opacity-100 hover:shadow-md active:translate-y-0"
@click=${this.jumpToBottom}
aria-label="Jump to bottom"
>