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-input.ts b/webui/src/web-components/mobile-chat-input.ts
index c41cdff..ff208d5 100644
--- a/webui/src/web-components/mobile-chat-input.ts
+++ b/webui/src/web-components/mobile-chat-input.ts
@@ -1,10 +1,11 @@
/* 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 { createRef, ref } from "lit/directives/ref.js";
+import { SketchTailwindElement } from "./sketch-tailwind-element";
@customElement("mobile-chat-input")
-export class MobileChatInput extends LitElement {
+export class MobileChatInput extends SketchTailwindElement {
@property({ type: Boolean })
disabled = false;
@@ -19,131 +20,6 @@
private textareaRef = createRef<HTMLTextAreaElement>();
- static styles = css`
- :host {
- display: block;
- background-color: #ffffff;
- border-top: 1px solid #e9ecef;
- padding: 12px 16px;
- /* Enhanced iOS safe area support */
- padding-bottom: max(12px, env(safe-area-inset-bottom));
- padding-left: max(16px, env(safe-area-inset-left));
- padding-right: max(16px, env(safe-area-inset-right));
- /* Prevent iOS Safari from covering the input */
- position: relative;
- z-index: 1000;
- }
-
- .input-container {
- display: flex;
- align-items: flex-end;
- gap: 12px;
- max-width: 100%;
- }
-
- .input-wrapper {
- flex: 1;
- position: relative;
- min-width: 0;
- }
-
- textarea {
- width: 100%;
- min-height: 40px;
- max-height: 120px;
- padding: 12px 16px;
- border: 1px solid #ddd;
- border-radius: 20px;
- font-size: 16px;
- font-family: inherit;
- line-height: 1.4;
- resize: none;
- outline: none;
- background-color: #f8f9fa;
- transition:
- border-color 0.2s,
- background-color 0.2s;
- box-sizing: border-box;
- }
-
- textarea:focus {
- border-color: #007bff;
- background-color: #ffffff;
- }
-
- textarea:disabled {
- background-color: #e9ecef;
- color: #6c757d;
- cursor: not-allowed;
- }
-
- textarea::placeholder {
- color: #6c757d;
- }
-
- .send-button {
- flex-shrink: 0;
- width: 40px;
- height: 40px;
- border: none;
- border-radius: 50%;
- background-color: #007bff;
- color: white;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 18px;
- transition:
- background-color 0.2s,
- transform 0.1s;
- outline: none;
- }
-
- .send-button:hover:not(:disabled) {
- background-color: #0056b3;
- }
-
- .send-button:active:not(:disabled) {
- transform: scale(0.95);
- }
-
- .send-button:disabled {
- background-color: #6c757d;
- cursor: not-allowed;
- opacity: 0.6;
- }
-
- .send-icon {
- width: 16px;
- height: 16px;
- fill: currentColor;
- }
-
- /* iOS specific adjustments */
- @supports (-webkit-touch-callout: none) {
- textarea {
- font-size: 16px; /* Prevent zoom on iOS */
- }
- }
-
- /* Upload progress indicator */
- .upload-progress {
- position: absolute;
- top: -30px;
- left: 50%;
- transform: translateX(-50%);
- background-color: #fff9c4;
- color: #856404;
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 12px;
- white-space: nowrap;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
- z-index: 1000;
- }
- `;
-
private handleInput = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
this.inputValue = target.value;
@@ -294,42 +170,51 @@
this.uploadsInProgress === 0;
return html`
- <div class="input-container">
- <div class="input-wrapper">
- <textarea
- ${ref(this.textareaRef)}
- .value=${this.inputValue}
- @input=${this.handleInput}
- @keydown=${this.handleKeyDown}
- placeholder="Message Sketch..."
- ?disabled=${this.disabled || this.uploadsInProgress > 0}
- rows="1"
- ></textarea>
+ <div
+ class="block bg-white border-t border-gray-200 p-3 relative z-[1000]"
+ style="padding-bottom: max(12px, env(safe-area-inset-bottom)); padding-left: max(16px, env(safe-area-inset-left)); padding-right: max(16px, env(safe-area-inset-right));"
+ >
+ <div class="flex items-end gap-3 max-w-full">
+ <div class="flex-1 relative min-w-0">
+ <textarea
+ ${ref(this.textareaRef)}
+ .value=${this.inputValue}
+ @input=${this.handleInput}
+ @keydown=${this.handleKeyDown}
+ placeholder="Message Sketch..."
+ ?disabled=${this.disabled || this.uploadsInProgress > 0}
+ rows="1"
+ class="w-full min-h-[40px] max-h-[120px] p-3 border border-gray-300 rounded-[20px] text-base font-inherit leading-relaxed resize-none outline-none bg-gray-50 transition-colors duration-200 box-border focus:border-blue-500 focus:bg-white disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed placeholder:text-gray-500"
+ style="font-size: 16px;"
+ ></textarea>
- ${this.showUploadProgress
- ? html`
- <div class="upload-progress">
- Uploading ${this.uploadsInProgress}
- file${this.uploadsInProgress > 1 ? "s" : ""}...
- </div>
- `
- : ""}
+ ${this.showUploadProgress
+ ? html`
+ <div
+ class="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-yellow-50 text-yellow-800 px-2 py-1 rounded text-xs whitespace-nowrap shadow-sm z-[1000]"
+ >
+ Uploading ${this.uploadsInProgress}
+ file${this.uploadsInProgress > 1 ? "s" : ""}...
+ </div>
+ `
+ : ""}
+ </div>
+
+ <button
+ class="flex-shrink-0 w-10 h-10 border-none rounded-full bg-blue-500 text-white cursor-pointer flex items-center justify-center text-lg transition-all duration-200 outline-none hover:bg-blue-600 active:scale-95 disabled:bg-gray-500 disabled:cursor-not-allowed disabled:opacity-60"
+ @click=${this.sendMessage}
+ ?disabled=${!canSend}
+ title=${this.uploadsInProgress > 0
+ ? "Please wait for upload to complete"
+ : "Send message"}
+ >
+ ${this.uploadsInProgress > 0
+ ? html`<span class="text-xs">⏳</span>`
+ : html`<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24">
+ <path d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
+ </svg>`}
+ </button>
</div>
-
- <button
- class="send-button"
- @click=${this.sendMessage}
- ?disabled=${!canSend}
- title=${this.uploadsInProgress > 0
- ? "Please wait for upload to complete"
- : "Send message"}
- >
- ${this.uploadsInProgress > 0
- ? html`<span style="font-size: 12px;">⏳</span>`
- : html`<svg class="send-icon" viewBox="0 0 24 24">
- <path d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
- </svg>`}
- </button>
</div>
`;
}
diff --git a/webui/src/web-components/mobile-chat.test.ts b/webui/src/web-components/mobile-chat.test.ts
index 54ddf2e..1b57f03 100644
--- a/webui/src/web-components/mobile-chat.test.ts
+++ b/webui/src/web-components/mobile-chat.test.ts
@@ -68,17 +68,10 @@
const errorBubble = component.locator(".message.error .message-bubble");
await expect(errorBubble).toBeVisible();
- // Verify the background color and text color
- const bgColor = await errorBubble.evaluate((el) => {
- return window.getComputedStyle(el).backgroundColor;
- });
- const textColor = await errorBubble.evaluate((el) => {
- return window.getComputedStyle(el).color;
- });
-
- // Check that we have red-ish colors (these will be RGB values)
- expect(bgColor).toMatch(/rgb\(255, 235, 238\)/); // #ffebee
- expect(textColor).toMatch(/rgb\(211, 47, 47\)/); // #d32f2f
+ // Verify the element has the correct CSS classes for red styling
+ const errorBubbleClasses = await errorBubble.getAttribute("class");
+ expect(errorBubbleClasses).toContain("bg-red-50");
+ expect(errorBubbleClasses).toContain("text-red-700");
});
test("filters messages correctly", async ({ mount }) => {
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"
>
diff --git a/webui/src/web-components/mobile-diff.ts b/webui/src/web-components/mobile-diff.ts
index 7511602..bf53b25 100644
--- a/webui/src/web-components/mobile-diff.ts
+++ b/webui/src/web-components/mobile-diff.ts
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
-import { css, html, LitElement } from "lit";
+import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
GitDiffFile,
@@ -7,9 +7,10 @@
DefaultGitDataService,
} from "./git-data-service";
import "./sketch-monaco-view";
+import { SketchTailwindElement } from "./sketch-tailwind-element";
@customElement("mobile-diff")
-export class MobileDiff extends LitElement {
+export class MobileDiff extends SketchTailwindElement {
private gitService: GitDataService = new DefaultGitDataService();
@state()
@@ -31,114 +32,6 @@
@state()
private fileExpandStates: Map<string, boolean> = new Map();
- static styles = css`
- :host {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
- overflow: hidden;
- background-color: #ffffff;
- }
-
- .diff-container {
- flex: 1;
- overflow: auto;
- min-height: 0;
- /* Ensure proper scrolling behavior */
- -webkit-overflow-scrolling: touch;
- }
-
- .loading,
- .error,
- .empty {
- display: flex;
- align-items: center;
- justify-content: center;
- height: 100%;
- font-size: 16px;
- color: #6c757d;
- text-align: center;
- padding: 20px;
- }
-
- .error {
- color: #dc3545;
- }
-
- .file-diff {
- margin-bottom: 16px;
- }
-
- .file-diff:last-child {
- margin-bottom: 0;
- }
-
- .file-header {
- background-color: #f8f9fa;
- border: 1px solid #e9ecef;
- border-bottom: none;
- padding: 12px 16px;
- font-family: monospace;
- font-size: 14px;
- font-weight: 500;
- color: #495057;
- position: sticky;
- top: 0;
- z-index: 10;
- }
-
- .file-status {
- display: inline-block;
- padding: 2px 6px;
- border-radius: 3px;
- font-size: 12px;
- font-weight: bold;
- margin-right: 8px;
- font-family: sans-serif;
- }
-
- .file-status.added {
- background-color: #d4edda;
- color: #155724;
- }
-
- .file-status.modified {
- background-color: #fff3cd;
- color: #856404;
- }
-
- .file-status.deleted {
- background-color: #f8d7da;
- color: #721c24;
- }
-
- .file-status.renamed {
- background-color: #d1ecf1;
- color: #0c5460;
- }
-
- .file-changes {
- margin-left: 8px;
- font-size: 12px;
- color: #6c757d;
- }
-
- .monaco-container {
- border: 1px solid #e9ecef;
- border-top: none;
- min-height: 200px;
- /* Prevent artifacts */
- overflow: hidden;
- background-color: #ffffff;
- }
-
- sketch-monaco-view {
- width: 100%;
- min-height: 200px;
- }
- `;
-
connectedCallback() {
super.connectedCallback();
this.loadDiffData();
@@ -247,6 +140,23 @@
}
}
+ private getFileStatusTailwindClass(status: string): string {
+ switch (status.toUpperCase()) {
+ case "A":
+ return "bg-green-100 text-green-800";
+ case "M":
+ return "bg-yellow-100 text-yellow-800";
+ case "D":
+ return "bg-red-100 text-red-800";
+ case "R":
+ default:
+ if (status.toUpperCase().startsWith("R")) {
+ return "bg-blue-100 text-blue-800";
+ }
+ return "bg-yellow-100 text-yellow-800";
+ }
+ }
+
private getFileStatusText(status: string): string {
switch (status.toUpperCase()) {
case "A":
@@ -357,46 +267,64 @@
if (!content) {
return html`
- <div class="file-diff">
- <div class="file-header">
- <div class="file-header-left">
- <span class="file-status ${this.getFileStatusClass(file.status)}">
+ <div class="mb-4 last:mb-0">
+ <div
+ class="bg-gray-50 border border-gray-200 border-b-0 p-3 font-mono text-sm font-medium text-gray-700 sticky top-0 z-10 flex items-center justify-between"
+ >
+ <div class="flex items-center">
+ <span
+ class="inline-block px-1.5 py-0.5 rounded text-xs font-bold mr-2 font-sans ${this.getFileStatusTailwindClass(
+ file.status,
+ )}"
+ >
${this.getFileStatusText(file.status)}
</span>
${this.getPathInfo(file)}
${this.getChangesInfo(file)
- ? html`<span class="file-changes"
+ ? html`<span class="ml-2 text-xs text-gray-500"
>${this.getChangesInfo(file)}</span
>`
: ""}
</div>
- <button class="file-expand-button" disabled>
+ <button class="text-gray-400" disabled>
${this.renderExpandAllIcon()}
</button>
</div>
- <div class="monaco-container">
- <div class="loading">Loading ${file.path}...</div>
+ <div
+ class="border border-gray-200 border-t-0 min-h-[200px] overflow-hidden bg-white"
+ >
+ <div
+ class="flex items-center justify-center h-full text-base text-gray-500 text-center p-5"
+ >
+ Loading ${file.path}...
+ </div>
</div>
</div>
`;
}
return html`
- <div class="file-diff">
- <div class="file-header">
- <div class="file-header-left">
- <span class="file-status ${this.getFileStatusClass(file.status)}">
+ <div class="mb-4 last:mb-0">
+ <div
+ class="bg-gray-50 border border-gray-200 border-b-0 p-3 font-mono text-sm font-medium text-gray-700 sticky top-0 z-10 flex items-center justify-between"
+ >
+ <div class="flex items-center">
+ <span
+ class="inline-block px-1.5 py-0.5 rounded text-xs font-bold mr-2 font-sans ${this.getFileStatusTailwindClass(
+ file.status,
+ )}"
+ >
${this.getFileStatusText(file.status)}
</span>
${this.getPathInfo(file)}
${this.getChangesInfo(file)
- ? html`<span class="file-changes"
+ ? html`<span class="ml-2 text-xs text-gray-500"
>${this.getChangesInfo(file)}</span
>`
: ""}
</div>
<button
- class="file-expand-button"
+ class="text-gray-600 hover:text-gray-800 p-1 rounded"
@click="${() => this.toggleFileExpansion(file.path)}"
title="${isExpanded
? "Collapse: Hide unchanged regions to focus on changes"
@@ -407,8 +335,11 @@
: this.renderExpandAllIcon()}
</button>
</div>
- <div class="monaco-container">
+ <div
+ class="border border-gray-200 border-t-0 min-h-[200px] overflow-hidden bg-white"
+ >
<sketch-monaco-view
+ class="w-full min-h-[200px]"
.originalCode="${content.original}"
.modifiedCode="${content.modified}"
.originalFilename="${file.path}"
@@ -424,14 +355,31 @@
render() {
return html`
- <div class="diff-container">
- ${this.loading
- ? html`<div class="loading">Loading diff...</div>`
- : this.error
- ? html`<div class="error">${this.error}</div>`
- : !this.files || this.files.length === 0
- ? html`<div class="empty">No changes to show</div>`
- : this.files.map((file) => this.renderFileDiff(file))}
+ <div class="flex flex-col h-full min-h-0 overflow-hidden bg-white">
+ <div
+ class="flex-1 overflow-auto min-h-0"
+ style="-webkit-overflow-scrolling: touch;"
+ >
+ ${this.loading
+ ? html`<div
+ class="flex items-center justify-center h-full text-base text-gray-500 text-center p-5"
+ >
+ Loading diff...
+ </div>`
+ : this.error
+ ? html`<div
+ class="flex items-center justify-center h-full text-base text-red-600 text-center p-5"
+ >
+ ${this.error}
+ </div>`
+ : !this.files || this.files.length === 0
+ ? html`<div
+ class="flex items-center justify-center h-full text-base text-gray-500 text-center p-5"
+ >
+ No changes to show
+ </div>`
+ : this.files.map((file) => this.renderFileDiff(file))}
+ </div>
</div>
`;
}
diff --git a/webui/src/web-components/mobile-shell.ts b/webui/src/web-components/mobile-shell.ts
index dd59ad8..5672be9 100644
--- a/webui/src/web-components/mobile-shell.ts
+++ b/webui/src/web-components/mobile-shell.ts
@@ -1,8 +1,9 @@
-import { css, html, LitElement } from "lit";
+import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ConnectionStatus, DataManager } from "../data";
import { AgentMessage, State } from "../types";
import { aggregateAgentMessages } from "./aggregateAgentMessages";
+import { SketchTailwindElement } from "./sketch-tailwind-element";
import "./mobile-title";
import "./mobile-chat";
@@ -10,7 +11,7 @@
import "./mobile-diff";
@customElement("mobile-shell")
-export class MobileShell extends LitElement {
+export class MobileShell extends SketchTailwindElement {
private dataManager = new DataManager();
@state()
@@ -25,55 +26,6 @@
@state()
currentView: "chat" | "diff" = "chat";
- static styles = css`
- :host {
- display: flex;
- flex-direction: column;
- /* Use dynamic viewport height for better iOS support */
- height: 100dvh;
- /* Fallback for browsers that don't support dvh */
- height: 100vh;
- /* iOS Safari custom property fallback */
- height: calc(var(--vh, 1vh) * 100);
- /* Additional iOS Safari fix */
- min-height: 100vh;
- min-height: -webkit-fill-available;
- width: 100vw;
- background-color: #ffffff;
- font-family:
- -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
- }
-
- .mobile-container {
- display: flex;
- flex-direction: column;
- height: 100%;
- overflow: hidden;
- }
-
- mobile-title {
- flex-shrink: 0;
- }
-
- mobile-chat {
- flex: 1;
- overflow: hidden;
- min-height: 0;
- }
-
- mobile-diff {
- flex: 1;
- overflow: hidden;
- min-height: 0;
- }
-
- mobile-chat-input {
- flex-shrink: 0;
- /* Ensure proper height calculation */
- min-height: 64px;
- }
- `;
-
connectedCallback() {
super.connectedCallback();
this.setupDataManager();
@@ -166,8 +118,12 @@
(this.state?.outstanding_tool_calls?.length ?? 0) > 0;
return html`
- <div class="mobile-container">
+ <div
+ class="flex flex-col bg-white font-sans w-screen overflow-hidden"
+ style="height: 100dvh; height: 100vh; height: calc(var(--vh, 1vh) * 100); min-height: 100vh; min-height: -webkit-fill-available;"
+ >
<mobile-title
+ class="flex-shrink-0"
.connectionStatus=${this.connectionStatus}
.isThinking=${isThinking}
.skabandAddr=${this.state?.skaband_addr}
@@ -179,13 +135,17 @@
${this.currentView === "chat"
? html`
<mobile-chat
+ class="flex-1 overflow-hidden min-h-0"
.messages=${this.messages}
.isThinking=${isThinking}
></mobile-chat>
`
- : html` <mobile-diff></mobile-diff> `}
+ : html`<mobile-diff
+ class="flex-1 overflow-hidden min-h-0"
+ ></mobile-diff>`}
<mobile-chat-input
+ class="flex-shrink-0 min-h-[64px]"
.disabled=${this.connectionStatus !== "connected"}
@send-message=${this.handleSendMessage}
></mobile-chat-input>
diff --git a/webui/src/web-components/mobile-title.ts b/webui/src/web-components/mobile-title.ts
index 8ac9166..92bde10 100644
--- a/webui/src/web-components/mobile-title.ts
+++ b/webui/src/web-components/mobile-title.ts
@@ -1,9 +1,10 @@
-import { css, html, LitElement } from "lit";
+import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ConnectionStatus } from "../data";
+import { SketchTailwindElement } from "./sketch-tailwind-element";
@customElement("mobile-title")
-export class MobileTitle extends LitElement {
+export class MobileTitle extends SketchTailwindElement {
@property({ type: String })
connectionStatus: ConnectionStatus = "disconnected";
@@ -19,161 +20,30 @@
@property({ type: String })
slug: string = "";
- static styles = css`
- :host {
- display: block;
- background-color: #f8f9fa;
- border-bottom: 1px solid #e9ecef;
- padding: 12px 16px;
+ connectedCallback() {
+ super.connectedCallback();
+ // Add animation styles to document head if not already present
+ if (!document.getElementById("mobile-title-animations")) {
+ const style = document.createElement("style");
+ style.id = "mobile-title-animations";
+ style.textContent = `
+ @keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+ }
+ @keyframes thinking {
+ 0%, 80%, 100% { transform: scale(0); }
+ 40% { transform: scale(1); }
+ }
+ .pulse-animation { animation: pulse 1.5s ease-in-out infinite; }
+ .thinking-animation { animation: thinking 1.4s ease-in-out infinite both; }
+ .thinking-animation:nth-child(1) { animation-delay: -0.32s; }
+ .thinking-animation:nth-child(2) { animation-delay: -0.16s; }
+ .thinking-animation:nth-child(3) { animation-delay: 0; }
+ `;
+ document.head.appendChild(style);
}
-
- .title-container {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- }
-
- .title-section {
- flex: 1;
- min-width: 0;
- }
-
- .right-section {
- display: flex;
- align-items: center;
- gap: 12px;
- }
-
- .view-selector {
- background: none;
- border: 1px solid #e9ecef;
- border-radius: 6px;
- padding: 6px 8px;
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s ease;
- color: #495057;
- min-width: 60px;
- }
-
- .view-selector:hover {
- background-color: #f8f9fa;
- border-color: #dee2e6;
- }
-
- .view-selector:focus {
- outline: none;
- border-color: #007acc;
- box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
- }
-
- .title {
- font-size: 18px;
- font-weight: 600;
- color: #212529;
- margin: 0;
- }
-
- .title a {
- color: inherit;
- text-decoration: none;
- transition: opacity 0.2s ease;
- display: flex;
- align-items: center;
- gap: 8px;
- }
-
- .title a:hover {
- opacity: 0.8;
- text-decoration: underline;
- }
-
- .title img {
- width: 18px;
- height: 18px;
- border-radius: 3px;
- }
-
- .status-indicator {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 14px;
- }
-
- .status-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- flex-shrink: 0;
- }
-
- .status-dot.connected {
- background-color: #28a745;
- }
-
- .status-dot.connecting {
- background-color: #ffc107;
- animation: pulse 1.5s ease-in-out infinite;
- }
-
- .status-dot.disconnected {
- background-color: #dc3545;
- }
-
- .thinking-indicator {
- display: flex;
- align-items: center;
- gap: 6px;
- color: #6c757d;
- font-size: 13px;
- }
-
- .thinking-dots {
- display: flex;
- gap: 2px;
- }
-
- .thinking-dot {
- width: 4px;
- height: 4px;
- 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 pulse {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.5;
- }
- }
-
- @keyframes thinking {
- 0%,
- 80%,
- 100% {
- transform: scale(0);
- }
- 40% {
- transform: scale(1);
- }
- }
- `;
+ }
private getStatusText() {
switch (this.connectionStatus) {
@@ -202,45 +72,67 @@
}
render() {
+ const statusDotClass =
+ {
+ connected: "bg-green-500",
+ connecting: "bg-yellow-500 pulse-animation",
+ disconnected: "bg-red-500",
+ }[this.connectionStatus] || "bg-gray-500";
+
return html`
- <div class="title-container">
- <div class="title-section">
- <h1 class="title">
- ${this.skabandAddr
- ? html`<a
- href="${this.skabandAddr}"
- target="_blank"
- rel="noopener noreferrer"
- >
- <img src="${this.skabandAddr}/sketch.dev.png" alt="sketch" />
- ${this.slug || "Sketch"}
- </a>`
- : html`${this.slug || "Sketch"}`}
- </h1>
- </div>
+ <div class="block bg-gray-50 border-b border-gray-200 p-3">
+ <div class="flex items-start justify-between">
+ <div class="flex-1 min-w-0">
+ <h1 class="text-lg font-semibold text-gray-900 m-0">
+ ${this.skabandAddr
+ ? html`<a
+ href="${this.skabandAddr}"
+ target="_blank"
+ rel="noopener noreferrer"
+ class="text-inherit no-underline transition-opacity duration-200 flex items-center gap-2 hover:opacity-80 hover:underline"
+ >
+ <img
+ src="${this.skabandAddr}/sketch.dev.png"
+ alt="sketch"
+ class="w-[18px] h-[18px] rounded"
+ />
+ ${this.slug || "Sketch"}
+ </a>`
+ : html`${this.slug || "Sketch"}`}
+ </h1>
+ </div>
- <div class="right-section">
- <select
- class="view-selector"
- .value=${this.currentView}
- @change=${this.handleViewChange}
- >
- <option value="chat">Chat</option>
- <option value="diff">Diff</option>
- </select>
+ <div class="flex items-center gap-3">
+ <select
+ class="bg-transparent border border-gray-200 rounded px-2 py-1.5 text-sm font-medium cursor-pointer transition-all duration-200 text-gray-700 min-w-[60px] hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:border-blue-500 focus:shadow-sm focus:ring-2 focus:ring-blue-200"
+ .value=${this.currentView}
+ @change=${this.handleViewChange}
+ >
+ <option value="chat">Chat</option>
+ <option value="diff">Diff</option>
+ </select>
- ${this.isThinking
- ? html`
- <div class="thinking-indicator">
- <span>thinking</span>
- <div class="thinking-dots">
- <div class="thinking-dot"></div>
- <div class="thinking-dot"></div>
- <div class="thinking-dot"></div>
+ ${this.isThinking
+ ? html`
+ <div class="flex items-center gap-1.5 text-gray-500 text-xs">
+ <span>thinking</span>
+ <div class="flex gap-0.5">
+ <div
+ class="w-1 h-1 rounded-full bg-gray-500 thinking-animation"
+ ></div>
+ <div
+ class="w-1 h-1 rounded-full bg-gray-500 thinking-animation"
+ ></div>
+ <div
+ class="w-1 h-1 rounded-full bg-gray-500 thinking-animation"
+ ></div>
+ </div>
</div>
- </div>
- `
- : html` <span class="status-dot ${this.connectionStatus}"></span> `}
+ `
+ : html`<span
+ class="w-2 h-2 rounded-full flex-shrink-0 ${statusDotClass}"
+ ></span>`}
+ </div>
</div>
</div>
`;