Overhaul UI with chat-like interface
Major UI improvements:
- Revamp timeline messages with chat-like interface
- User messages now on right with white text on blue background
- Agent/tool messages on left with black text on grey background
- Chat bubbles extend up to 80% of screen width
- Maintain left-aligned text for code readability
- Move metadata to outer gutters
- Show turn duration for end-of-turn messages
- Integrate tool calls within agent message bubbles
- Add thinking indicator with animated dots when LLM is processing
- Replace buttons with intuitive icons (copy, info, etc.)
- Improve tool call presentation
- Simplify to single row design with all essential info
- Add clear status indicators for success/pending/error
- Fix horizontal scrolling for long commands and outputs
- Prevent tool name truncation
- Improve spacing and alignment throughout
- Enhance header and status displays
- Move Last Commit to dedicated third column in header grid
- Add proper labeling with two-row structure
- Provide consistent styling across all status elements
- Other UI refinements
- Add root URL redirection to demo page
- Fix spacing throughout the interface
- Optimize CSS for better performance
- Ensure consistent styling across components
- Improve command output display and wrapping
Co-Authored-By: sketch <hello@sketch.dev>
diff --git a/webui/package.json b/webui/package.json
index 0248c77..ef77988 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -12,7 +12,7 @@
"scripts": {
"playwright-install": "playwright install",
"check": "tsc --noEmit",
- "demo": "vite --open src/web-components/demo/index.html",
+ "demo": "vite --open /src/web-components/demo/index.html",
"demo:mermaid": "vite --open src/web-components/demo/mermaid-test/index.html",
"dev": "vite --port 5173 --strictPort --host 127.0.0.1",
"format": "prettier ./src --write",
diff --git a/webui/src/web-components/demo/index.html b/webui/src/web-components/demo/index.html
index 77df51e..e8db7d8 100644
--- a/webui/src/web-components/demo/index.html
+++ b/webui/src/web-components/demo/index.html
@@ -6,7 +6,6 @@
sketch web-components demo index
<ul>
<li><a href="sketch-app-shell.demo.html">sketch-app-shell</a></li>
- <li><a href="sketch-charts.demo.html">sketch-charts</a></li>
<li><a href="sketch-chat-input.demo.html">sketch-chat-input</a></li>
<li><a href="sketch-diff-view.demo.html">sketch-diff-view</a></li>
<li>
diff --git a/webui/src/web-components/demo/sketch-timeline.demo.html b/webui/src/web-components/demo/sketch-timeline.demo.html
index 58ff5d9..9c817d8 100644
--- a/webui/src/web-components/demo/sketch-timeline.demo.html
+++ b/webui/src/web-components/demo/sketch-timeline.demo.html
@@ -8,38 +8,129 @@
{
type: "user",
content: "a user message",
+ timestamp: "2025-04-14T16:39:30.639533919Z",
+ conversation_id: "conv-123456",
+ idx: 0,
},
{
type: "agent",
- content: "an agent message",
+ content: "an agent message with usage information",
+ timestamp: "2025-04-14T16:39:31.639533919Z",
+ conversation_id: "conv-123456",
+ idx: 1,
+ usage: {
+ input_tokens: 4,
+ cache_creation_input_tokens: 2620,
+ cache_read_input_tokens: 0,
+ output_tokens: 106,
+ cost_usd: 0.011427,
+ },
},
{
type: "agent",
- content: "an agent message",
+ content: "an agent message with a single tool call",
+ timestamp: "2025-04-14T16:39:32.639533919Z",
+ conversation_id: "conv-123456",
+ idx: 2,
+ tool_calls: [
+ {
+ name: "bash",
+ input: 'find . -type f -name "*.go" | wc -l',
+ tool_call_id: "call_12345",
+ args: '{"command": "find . -type f -name \\"*.go\\" | wc -l"}',
+ result: "486",
+ },
+ ],
},
{
type: "agent",
- content: "an agent message",
+ content:
+ "an agent message with a bash command that is extremely long and would create a horizontal scrollbar",
+ timestamp: "2025-04-14T16:39:32.739533919Z",
+ conversation_id: "conv-123456",
+ idx: 2.5,
+ tool_calls: [
+ {
+ name: "bash",
+ input:
+ 'find /app -type f -name "*.ts" -o -name "*.js" -o -name "*.tsx" -o -name "*.jsx" | xargs grep -l "useState" | while read file; do echo "File: $file"; grep -n "useState" $file | head -5; echo; done | head -50',
+ tool_call_id: "call_verylongbash",
+ args: '{"command": "find /app -type f -name \\"*.ts\\" -o -name \\"*.js\\" -o -name \\"*.tsx\\" -o -name \\"*.jsx\\" | xargs grep -l \\"useState\\" | while read file; do echo \\"File: $file\\"; grep -n \\"useState\\" $file | head -5; echo; done | head -50"}',
+ result:
+ 'File: /app/webui/src/web-components/sketch-chat-input.ts\n97: useState,\n\nFile: /app/webui/src/web-components/sketch-diff-view.ts\n47:import { createRef, ref, useState } from "lit/directives/ref.js";\n110: const [selectedFiles, setSelectedFiles] = useState([]);\n',
+ },
+ ],
+ },
+ {
+ type: "agent",
+ content:
+ "an agent message with a bash command that has a very long output that would create a horizontal scrollbar",
+ timestamp: "2025-04-14T16:39:32.839533919Z",
+ conversation_id: "conv-123456",
+ idx: 2.7,
+ tool_calls: [
+ {
+ name: "bash",
+ input: "cat /app/webui/package.json | grep -A 5 dependencies",
+ tool_call_id: "call_longoutput",
+ args: '{"command": "cat /app/webui/package.json | grep -A 5 dependencies"}',
+ result:
+ ' "dependencies": {\n "@xterm/addon-fit": "^0.10.0",\n "@xterm/xterm": "^5.5.0",\n "diff2html": "3.4.51",\n "lit": "^3.2.1",\n "marked": "^15.0.7",\n "mermaid": "^11.6.0",\n "sanitize-html": "^2.15.0",\n "vega": "^5.33.0",\n "vega-embed": "^6.29.0",\n "vega-lite": "^5.23.0",\n "react": "^18.2.0",\n "react-dom": "^18.2.0",\n "styled-components": "^6.1.8",\n "tailwindcss": "^3.4.1",\n "typescript": "^5.3.3",\n "zod": "^3.22.4",\n "@types/react": "^18.2.55",\n "@types/react-dom": "^18.2.19",\n "eslint": "^8.56.0",\n "prettier": "^3.2.5"\n },',
+ },
+ ],
+ },
+ {
+ type: "agent",
+ content:
+ "an agent message with two tool calls that will show how the width behaves with very long content that should push the boundaries of the UI layout with really wide tool calls that might stretch beyond the regular message content width",
+ timestamp: "2025-04-14T16:39:33.639533919Z",
+ conversation_id: "conv-123456",
+ idx: 3,
+ tool_calls: [
+ {
+ name: "keyword_search",
+ input: "Search for files related to the timeline component",
+ tool_call_id: "call_67890",
+ args: '{"query": "Find all files related to the timeline component in the project", "search_terms": ["timeline", "message", "component", "web-components"]}',
+ result:
+ "Found 3 files: sketch-timeline.ts, sketch-timeline-message.ts, sketch-timeline.demo.html",
+ },
+ {
+ name: "patch",
+ input: "Update the timeline component CSS",
+ tool_call_id: "call_abcdef",
+ args: '{"path": "/app/webui/src/web-components/sketch-timeline.ts", "patches": [{"operation": "replace", "oldText": "width: 100%;", "newText": "width: auto; max-width: 100%;"}, {"operation": "replace", "oldText": "margin-bottom: 20px;", "newText": "margin-bottom: 24px;"}]}',
+ result: "Applied all patches successfully",
+ },
+ ],
},
{
type: "user",
- content: "a user message",
- },
- {
- type: "user",
- content: "a user message",
+ content: "another user message",
+ timestamp: "2025-04-14T16:39:34.639533919Z",
+ conversation_id: "conv-123456",
+ idx: 4,
},
{
type: "agent",
- content: "an agent message",
- },
- {
- type: "user",
- content: "a user message",
+ content: "an agent message with detailed information and usage data",
+ timestamp: "2025-04-14T16:39:35.639533919Z",
+ conversation_id: "conv-123456",
+ idx: 5,
+ usage: {
+ input_tokens: 125,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 3050,
+ output_tokens: 245,
+ cost_usd: 0.023456,
+ },
},
{
type: "tool",
content: "a tool use message",
+ timestamp: "2025-04-14T16:39:36.639533919Z",
+ conversation_id: "conv-123456",
+ idx: 6,
},
{
type: "commit",
@@ -54,14 +145,24 @@
"sketch/create-readmemd-for-web-components-directory",
},
],
- timestamp: "2025-04-14T16:39:33.639533919Z",
- conversation_id: "",
- idx: 17,
+ timestamp: "2025-04-14T16:39:37.639533919Z",
+ conversation_id: "conv-123456",
+ idx: 7,
},
{
type: "agent",
content: "an end-of-turn agent message",
end_of_turn: true,
+ timestamp: "2025-04-14T16:39:38.639533919Z",
+ conversation_id: "conv-123456",
+ idx: 8,
+ usage: {
+ input_tokens: 85,
+ cache_creation_input_tokens: 1240,
+ cache_read_input_tokens: 750,
+ output_tokens: 178,
+ cost_usd: 0.018976,
+ },
},
];
diff --git a/webui/src/web-components/sketch-app-shell.ts b/webui/src/web-components/sketch-app-shell.ts
index d351339..c9553b4 100644
--- a/webui/src/web-components/sketch-app-shell.ts
+++ b/webui/src/web-components/sketch-app-shell.ts
@@ -32,46 +32,15 @@
// Last commit information
@state()
- lastCommit: { hash: string; pushedBranch?: string } | null = null;
+
+ // Reference to the container status element
+ containerStatusElement: any = null;
// See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
// Note that these styles only apply to the scope of this web component's
// shadow DOM node, so they won't leak out or collide with CSS declared in
// other components or the containing web page (...unless you want it to do that).
static styles = css`
- /* Last commit display styling */
- .last-commit {
- display: flex;
- align-items: center;
- padding: 3px 8px;
- background: #f0f7ff;
- border: 1px solid #c8e1ff;
- border-radius: 4px;
- font-family: monospace;
- font-size: 12px;
- color: #0366d6;
- cursor: pointer;
- position: relative;
- margin: 0 10px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 180px;
- transition: background-color 0.2s ease;
- }
-
- .last-commit:hover {
- background-color: #dbedff;
- }
-
- .last-commit::before {
- content: "Last Commit: ";
- color: #666;
- margin-right: 4px;
- font-family: system-ui, sans-serif;
- font-size: 11px;
- }
-
.copied-indicator {
position: absolute;
top: -20px;
@@ -326,7 +295,7 @@
// Track if the last commit info has been copied
@state()
- lastCommitCopied: boolean = false;
+ // lastCommitCopied moved to sketch-container-status
// Track notification preferences
@state()
@@ -386,6 +355,9 @@
constructor() {
super();
+ // Reference to the container status element
+ this.containerStatusElement = null;
+
// Binding methods to this
this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
@@ -412,6 +384,12 @@
connectedCallback() {
super.connectedCallback();
+ // Get reference to the container status element
+ setTimeout(() => {
+ this.containerStatusElement =
+ this.shadowRoot?.getElementById("container-status");
+ }, 0);
+
// Initialize client-side nav history.
const url = new URL(window.location.href);
const mode = url.searchParams.get("view") || "chat";
@@ -451,7 +429,12 @@
// Process existing messages for commit info
if (this.messages && this.messages.length > 0) {
- this.updateLastCommitInfo(this.messages);
+ // Update last commit info via container status component
+ setTimeout(() => {
+ if (this.containerStatusElement) {
+ this.containerStatusElement.updateLastCommitInfo(this.messages);
+ }
+ }, 100);
}
}
@@ -775,7 +758,10 @@
this.messages = aggregateAgentMessages(this.messages, newMessages);
// Process new messages to find commit messages
- this.updateLastCommitInfo(newMessages);
+ // Update last commit info via container status component
+ if (this.containerStatusElement) {
+ this.containerStatusElement.updateLastCommitInfo(newMessages);
+ }
// Check for agent messages with end_of_turn=true and show notifications
if (newMessages && newMessages.length > 0) {
@@ -803,54 +789,6 @@
this.updateDocumentTitle();
}
- // Update last commit information when new messages arrive
- private updateLastCommitInfo(newMessages: AgentMessage[]): void {
- if (!newMessages || newMessages.length === 0) return;
-
- // Process messages in chronological order (latest last)
- for (const message of newMessages) {
- if (
- message.type === "commit" &&
- message.commits &&
- message.commits.length > 0
- ) {
- // Get the first commit from the list
- const commit = message.commits[0];
- if (commit) {
- this.lastCommit = {
- hash: commit.hash,
- pushedBranch: commit.pushed_branch,
- };
- this.lastCommitCopied = false;
- }
- }
- }
- }
-
- // Copy commit info to clipboard
- private copyCommitInfo(event: MouseEvent): void {
- event.preventDefault();
- event.stopPropagation();
-
- if (!this.lastCommit) return;
-
- const textToCopy =
- this.lastCommit.pushedBranch || this.lastCommit.hash.substring(0, 8);
-
- navigator.clipboard
- .writeText(textToCopy)
- .then(() => {
- this.lastCommitCopied = true;
- // Reset the copied state after 2 seconds
- setTimeout(() => {
- this.lastCommitCopied = false;
- }, 2000);
- })
- .catch((err) => {
- console.error("Failed to copy commit info:", err);
- });
- }
-
private async _handleStopClick(): Promise<void> {
try {
const response = await fetch("cancel", {
@@ -936,31 +874,13 @@
<!-- Container status info moved above tabs -->
<sketch-container-status
.state=${this.containerState}
+ id="container-status"
></sketch-container-status>
- <!-- Views section with tabs - repositioned -->
- <sketch-view-mode-select></sketch-view-mode-select>
+ <!-- Last Commit section moved to sketch-container-status -->
- ${this.lastCommit
- ? html`
- <div
- class="last-commit"
- @click=${(e: MouseEvent) => this.copyCommitInfo(e)}
- title="Click to copy"
- >
- ${this.lastCommitCopied
- ? html`<span class="copied-indicator">Copied!</span>`
- : ""}
- ${this.lastCommit.pushedBranch
- ? html`<span class="commit-branch-indicator"
- >${this.lastCommit.pushedBranch}</span
- >`
- : html`<span class="commit-hash-indicator"
- >${this.lastCommit.hash.substring(0, 8)}</span
- >`}
- </div>
- `
- : ""}
+ <!-- Views section with tabs -->
+ <sketch-view-mode-select></sketch-view-mode-select>
<div class="refresh-control">
<button
@@ -1054,6 +974,9 @@
<sketch-timeline
.messages=${this.messages}
.scrollContainer=${this.scrollContainerRef}
+ .agentState=${this.containerState?.agent_state}
+ .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
+ .toolCalls=${this.containerState?.outstanding_tool_calls || []}
></sketch-timeline>
</div>
<div
@@ -1124,7 +1047,10 @@
// Process any existing messages to find commit information
if (this.messages && this.messages.length > 0) {
- this.updateLastCommitInfo(this.messages);
+ // Update last commit info via container status component
+ if (this.containerStatusElement) {
+ this.containerStatusElement.updateLastCommitInfo(this.messages);
+ }
}
}
}
diff --git a/webui/src/web-components/sketch-container-status.ts b/webui/src/web-components/sketch-container-status.ts
index ba745cc..a0db867 100644
--- a/webui/src/web-components/sketch-container-status.ts
+++ b/webui/src/web-components/sketch-container-status.ts
@@ -1,4 +1,4 @@
-import { State } from "../types";
+import { State, AgentMessage } from "../types";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { formatNumber } from "../utils";
@@ -13,11 +13,92 @@
@state()
showDetails: boolean = false;
+ @state()
+ lastCommit: { hash: string; pushedBranch?: string } | null = null;
+
+ @state()
+ lastCommitCopied: boolean = false;
+
// See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
// Note that these styles only apply to the scope of this web component's
// shadow DOM node, so they won't leak out or collide with CSS declared in
// other components or the containing web page (...unless you want it to do that).
static styles = css`
+ /* Last commit display styling */
+ .last-commit {
+ display: flex;
+ flex-direction: column;
+ padding: 3px 8px;
+ cursor: pointer;
+ position: relative;
+ margin: 4px 0;
+ transition: color 0.2s ease;
+ }
+
+ .last-commit:hover {
+ color: #0366d6;
+ }
+
+ .last-commit-title {
+ color: #666;
+ font-family: system-ui, sans-serif;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.2;
+ }
+
+ .last-commit-hash {
+ font-family: monospace;
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ /* Styles for the last commit in main grid */
+ .last-commit-column {
+ justify-content: flex-start;
+ }
+
+ .info-label {
+ color: #666;
+ font-family: system-ui, sans-serif;
+ font-size: 11px;
+ font-weight: 500;
+ }
+
+ .last-commit-main {
+ cursor: pointer;
+ position: relative;
+ padding-top: 0;
+ }
+
+ .last-commit-main:hover {
+ color: #0366d6;
+ }
+
+ .main-grid-commit {
+ font-family: monospace;
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .commit-hash-indicator {
+ color: #666;
+ }
+
+ .commit-branch-indicator {
+ color: #28a745;
+ }
+
+ .no-commit-indicator {
+ color: #999;
+ font-style: italic;
+ font-size: 12px;
+ }
+
.info-container {
display: flex;
align-items: center;
@@ -71,6 +152,7 @@
.info-value {
font-size: 11px;
font-weight: 600;
+ word-break: break-all;
}
[title] {
@@ -115,8 +197,10 @@
}
.main-info-grid {
- display: flex;
- gap: 20px;
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 10px;
+ width: 100%;
}
.info-column {
@@ -232,6 +316,58 @@
this.requestUpdate();
}
+ /**
+ * Update the last commit information based on messages
+ */
+ public updateLastCommitInfo(newMessages: AgentMessage[]): void {
+ if (!newMessages || newMessages.length === 0) return;
+
+ // Process messages in chronological order (latest last)
+ for (const message of newMessages) {
+ if (
+ message.type === "commit" &&
+ message.commits &&
+ message.commits.length > 0
+ ) {
+ // Get the first commit from the list
+ const commit = message.commits[0];
+ if (commit) {
+ this.lastCommit = {
+ hash: commit.hash,
+ pushedBranch: commit.pushed_branch,
+ };
+ this.lastCommitCopied = false;
+ }
+ }
+ }
+ }
+
+ /**
+ * Copy commit info to clipboard when clicked
+ */
+ private copyCommitInfo(event: MouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (!this.lastCommit) return;
+
+ const textToCopy =
+ this.lastCommit.pushedBranch || this.lastCommit.hash.substring(0, 8);
+
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ this.lastCommitCopied = true;
+ // Reset the copied state after 2 seconds
+ setTimeout(() => {
+ this.lastCommitCopied = false;
+ }, 2000);
+ })
+ .catch((err) => {
+ console.error("Failed to copy commit info:", err);
+ });
+ }
+
formatHostname() {
// Only display outside hostname
const outsideHostname = this.state?.outside_hostname;
@@ -472,6 +608,31 @@
>
</div>
</div>
+
+ <!-- Third column: Last Commit -->
+ <div class="info-column last-commit-column">
+ <div class="info-item">
+ <span class="info-label">Last Commit</span>
+ </div>
+ <div
+ class="info-item last-commit-main"
+ @click=${(e: MouseEvent) => this.copyCommitInfo(e)}
+ title="Click to copy"
+ >
+ ${this.lastCommitCopied
+ ? html`<span class="copied-indicator">Copied!</span>`
+ : ""}
+ ${this.lastCommit
+ ? this.lastCommit.pushedBranch
+ ? html`<span class="commit-branch-indicator main-grid-commit"
+ >${this.lastCommit.pushedBranch}</span
+ >`
+ : html`<span class="commit-hash-indicator main-grid-commit"
+ >${this.lastCommit.hash.substring(0, 8)}</span
+ >`
+ : html`<span class="no-commit-indicator">N/A</span>`}
+ </div>
+ </div>
</div>
<!-- Info toggle button -->
@@ -485,6 +646,8 @@
<!-- Expanded info panel -->
<div class="info-expanded ${this.showDetails ? "active" : ""}">
+ <!-- Last Commit section moved to main grid -->
+
<div class="detailed-info-grid">
<div class="info-item">
<span class="info-label">Commit:</span>
@@ -499,6 +662,12 @@
>
</div>
<div class="info-item">
+ <span class="info-label">Session ID:</span>
+ <span id="sessionId" class="info-value"
+ >${this.state?.session_id || "N/A"}</span
+ >
+ </div>
+ <div class="info-item">
<span class="info-label">Input tokens:</span>
<span id="inputTokens" class="info-value"
>${formatNumber(
diff --git a/webui/src/web-components/sketch-timeline-message.test.ts b/webui/src/web-components/sketch-timeline-message.test.ts
index 3d9b799..bdab45a 100644
--- a/webui/src/web-components/sketch-timeline-message.test.ts
+++ b/webui/src/web-components/sketch-timeline-message.test.ts
@@ -85,6 +85,7 @@
test("formats timestamps correctly", async ({ mount }) => {
const message = createMockMessage({
timestamp: "2023-05-15T12:00:00Z",
+ type: "agent",
});
const component = await mount(SketchTimelineMessage, {
@@ -93,15 +94,36 @@
},
});
- await expect(component.locator(".message-timestamp")).toBeVisible();
- // Should include a formatted date like "May 15, 2023"
- await expect(component.locator(".message-timestamp")).toContainText(
+ // Toggle the info panel to view timestamps
+ await component.locator(".info-icon").click();
+ await expect(component.locator(".message-info-panel")).toBeVisible();
+
+ // Find the timestamp in the info panel
+ const timeInfoRow = component.locator(".info-row", { hasText: "Time:" });
+ await expect(timeInfoRow).toBeVisible();
+ await expect(timeInfoRow.locator(".info-value")).toContainText(
"May 15, 2023",
);
- // Should include elapsed time
- await expect(component.locator(".message-timestamp")).toContainText(
- "(1.50s)",
- );
+ // For end-of-turn messages, duration is shown separately
+ const endOfTurnMessage = createMockMessage({
+ timestamp: "2023-05-15T12:00:00Z",
+ type: "agent",
+ end_of_turn: true,
+ });
+
+ const endOfTurnComponent = await mount(SketchTimelineMessage, {
+ props: {
+ message: endOfTurnMessage,
+ },
+ });
+
+ // For end-of-turn messages, duration is shown in the end-of-turn indicator
+ await expect(
+ endOfTurnComponent.locator(".end-of-turn-indicator"),
+ ).toBeVisible();
+ await expect(
+ endOfTurnComponent.locator(".end-of-turn-indicator"),
+ ).toContainText("1.5s");
});
test("renders markdown content correctly", async ({ mount }) => {
@@ -148,14 +170,24 @@
},
});
- await expect(component.locator(".message-usage")).toBeVisible();
- await expect(component.locator(".message-usage")).toContainText(
- "200".toLocaleString(),
- ); // In (150 + 50 cache)
- await expect(component.locator(".message-usage")).toContainText(
- "300".toLocaleString(),
- ); // Out
- await expect(component.locator(".message-usage")).toContainText("$0.03"); // Cost
+ // Toggle the info panel to view usage information
+ await component.locator(".info-icon").click();
+ await expect(component.locator(".message-info-panel")).toBeVisible();
+
+ // Find the tokens info in the info panel
+ const tokensInfoRow = component.locator(".info-row", { hasText: "Tokens:" });
+ await expect(tokensInfoRow).toBeVisible();
+ await expect(tokensInfoRow).toContainText("Input: " + "150".toLocaleString());
+ await expect(tokensInfoRow).toContainText(
+ "Cache read: " + "50".toLocaleString(),
+ );
+ // Check for output tokens
+ await expect(tokensInfoRow).toContainText(
+ "Output: " + "300".toLocaleString(),
+ );
+
+ // Check for cost
+ await expect(tokensInfoRow).toContainText("Cost: $0.03");
});
test("renders commit information correctly", async ({ mount }) => {
@@ -179,8 +211,10 @@
});
await expect(component.locator(".commits-container")).toBeVisible();
- await expect(component.locator(".commits-header")).toBeVisible();
- await expect(component.locator(".commits-header")).toContainText("1 new");
+ await expect(component.locator(".commit-notification")).toBeVisible();
+ await expect(component.locator(".commit-notification")).toContainText(
+ "1 new",
+ );
await expect(component.locator(".commit-hash")).toBeVisible();
await expect(component.locator(".commit-hash")).toHaveText("12345678"); // First 8 chars
diff --git a/webui/src/web-components/sketch-timeline-message.ts b/webui/src/web-components/sketch-timeline-message.ts
index 118fe36..b148203 100644
--- a/webui/src/web-components/sketch-timeline-message.ts
+++ b/webui/src/web-components/sketch-timeline-message.ts
@@ -1,6 +1,6 @@
-import { css, html, LitElement } from "lit";
+import { css, html, LitElement, render } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
-import { customElement, property } from "lit/decorators.js";
+import { customElement, property, state } from "lit/decorators.js";
import { AgentMessage } from "../types";
import { marked, MarkedOptions, Renderer, Tokens } from "marked";
import mermaid from "mermaid";
@@ -16,6 +16,9 @@
@property()
open: boolean = false;
+ @state()
+ showInfo: boolean = false;
+
// See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
// Note that these styles only apply to the scope of this web component's
// shadow DOM node, so they won't leak out or collide with CSS declared in
@@ -23,31 +26,76 @@
static styles = css`
.message {
position: relative;
- margin-bottom: 5px;
- padding-left: 30px;
+ margin-bottom: 6px;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
}
- .message-icon {
- position: absolute;
- left: 10px;
- top: 0;
- transform: translateX(-50%);
- width: 16px;
- height: 16px;
- border-radius: 3px;
- text-align: center;
- line-height: 16px;
- color: #fff;
- font-size: 10px;
+ .message-container {
+ display: flex;
+ position: relative;
+ width: 100%;
+ }
+
+ .message-metadata-left {
+ flex: 0 0 80px;
+ padding: 3px 5px;
+ text-align: right;
+ font-size: 11px;
+ color: #777;
+ align-self: flex-start;
+ }
+
+ .message-metadata-right {
+ flex: 0 0 80px;
+ padding: 3px 5px;
+ text-align: left;
+ font-size: 11px;
+ color: #777;
+ align-self: flex-start;
+ }
+
+ .message-bubble-container {
+ flex: 1;
+ display: flex;
+ max-width: calc(100% - 160px);
+ }
+
+ .user .message-bubble-container {
+ justify-content: flex-end;
+ }
+
+ .agent .message-bubble-container,
+ .tool .message-bubble-container,
+ .error .message-bubble-container {
+ justify-content: flex-start;
}
.message-content {
position: relative;
- padding: 5px 10px;
- background: #fff;
- border-radius: 3px;
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
- border-left: 3px solid transparent;
+ padding: 6px 10px;
+ border-radius: 12px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+ max-width: 80%;
+ width: fit-content;
+ min-width: min-content;
+ }
+
+ /* User message styling */
+ .user .message-content {
+ background-color: #2196f3;
+ color: white;
+ border-bottom-right-radius: 5px;
+ }
+
+ /* Agent message styling */
+ .agent .message-content,
+ .tool .message-content,
+ .error .message-content {
+ background-color: #f1f1f1;
+ color: black;
+ border-bottom-left-radius: 5px;
}
/* Copy button styles */
@@ -70,51 +118,73 @@
opacity: 1;
}
- .copy-button {
- background-color: rgba(255, 255, 255, 0.9);
- border: 1px solid #ddd;
- border-radius: 4px;
- color: #555;
- cursor: pointer;
- font-size: 12px;
- padding: 2px 8px;
- transition: all 0.2s ease;
- }
-
- .copy-button:hover {
- background-color: #f0f0f0;
- color: #333;
- }
-
- /* Removed arrow decoration for a more compact look */
-
- .message-header {
+ .message-actions {
display: flex;
- flex-wrap: wrap;
- gap: 5px;
- margin-bottom: 3px;
- font-size: 12px;
+ gap: 6px;
+ }
+
+ .copy-icon,
+ .info-icon {
+ background-color: transparent;
+ border: none;
+ color: rgba(0, 0, 0, 0.6);
+ cursor: pointer;
+ padding: 3px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ transition: all 0.15s ease;
+ }
+
+ .user .copy-icon,
+ .user .info-icon {
+ color: rgba(255, 255, 255, 0.8);
+ }
+
+ .copy-icon:hover,
+ .info-icon:hover {
+ background-color: rgba(0, 0, 0, 0.08);
+ }
+
+ .user .copy-icon:hover,
+ .user .info-icon:hover {
+ background-color: rgba(255, 255, 255, 0.15);
+ }
+
+ /* Message metadata styling */
+ .message-type {
+ font-weight: bold;
+ font-size: 11px;
}
.message-timestamp {
+ display: block;
font-size: 10px;
color: #888;
- font-style: italic;
- margin-left: 3px;
+ margin-top: 2px;
+ }
+
+ .message-duration {
+ display: block;
+ font-size: 10px;
+ color: #888;
+ margin-top: 2px;
}
.message-usage {
+ display: block;
font-size: 10px;
color: #888;
- margin-left: 3px;
+ margin-top: 3px;
}
.conversation-id {
font-family: monospace;
font-size: 12px;
padding: 2px 4px;
- background-color: #f0f0f0;
- border-radius: 3px;
margin-left: auto;
}
@@ -132,18 +202,37 @@
.message-text {
overflow-x: auto;
- margin-bottom: 3px;
- font-family: monospace;
- padding: 3px 5px;
- background: rgb(236, 236, 236);
- border-radius: 6px;
+ margin-bottom: 0;
+ font-family: sans-serif;
+ padding: 2px 0;
user-select: text;
cursor: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
- font-size: 13px;
- line-height: 1.3;
+ font-size: 14px;
+ line-height: 1.35;
+ text-align: left;
+ }
+
+ /* Style for code blocks within messages */
+ .message-text pre,
+ .message-text code {
+ font-family: monospace;
+ background: rgba(0, 0, 0, 0.05);
+ border-radius: 4px;
+ padding: 2px 4px;
+ overflow-x: auto;
+ max-width: 100%;
+ white-space: pre-wrap; /* Allow wrapping for very long lines */
+ word-break: break-all; /* Break words at any character */
+ box-sizing: border-box; /* Include padding in width calculation */
+ }
+
+ .user .message-text pre,
+ .user .message-text code {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
}
.tool-details {
@@ -219,150 +308,87 @@
}
/* Commit message styling */
- .message.commit {
- background-color: #f0f7ff;
- border-left: 4px solid #0366d6;
- }
-
.commits-container {
margin-top: 10px;
- padding: 5px;
}
- .commits-header {
- font-weight: bold;
- margin-bottom: 5px;
- color: #24292e;
- display: flex;
- justify-content: space-between;
- align-items: center;
+ .commit-notification {
+ background-color: #e8f5e9;
+ color: #2e7d32;
+ font-weight: 500;
+ font-size: 12px;
+ padding: 6px 10px;
+ border-radius: 10px;
+ margin-bottom: 8px;
+ text-align: center;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
- .commit-boxes-row {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 8px;
- }
-
- .commit-box {
- border: 1px solid #d1d5da;
- border-radius: 4px;
+ .commit-card {
+ background-color: #f5f5f5;
+ border-radius: 8px;
overflow: hidden;
- background-color: #ffffff;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
- max-width: 100%;
- display: flex;
- flex-direction: column;
- }
-
- .commit-preview {
- padding: 8px 12px;
- font-family: monospace;
- background-color: #f6f8fa;
- border-bottom: 1px dashed #d1d5da;
+ margin-bottom: 6px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
+ padding: 6px 8px;
display: flex;
align-items: center;
- flex-wrap: wrap;
- gap: 4px;
- }
-
- .commit-preview:hover {
- background-color: #eef2f6;
+ gap: 8px;
}
.commit-hash {
color: #0366d6;
font-weight: bold;
+ font-family: monospace;
cursor: pointer;
- margin-right: 8px;
text-decoration: none;
- position: relative;
+ background-color: rgba(3, 102, 214, 0.08);
+ padding: 2px 5px;
+ border-radius: 4px;
}
.commit-hash:hover {
- text-decoration: underline;
- }
-
- .commit-hash:hover::after {
- content: "📋";
- font-size: 10px;
- position: absolute;
- top: -8px;
- right: -12px;
- opacity: 0.7;
- }
-
- .branch-wrapper {
- margin-right: 8px;
- color: #555;
+ background-color: rgba(3, 102, 214, 0.15);
}
.commit-branch {
color: #28a745;
font-weight: 500;
cursor: pointer;
- text-decoration: none;
- position: relative;
+ font-family: monospace;
+ background-color: rgba(40, 167, 69, 0.08);
+ padding: 2px 5px;
+ border-radius: 4px;
}
.commit-branch:hover {
- text-decoration: underline;
+ background-color: rgba(40, 167, 69, 0.15);
}
- .commit-branch:hover::after {
- content: "📋";
- font-size: 10px;
- position: absolute;
- top: -8px;
- right: -12px;
- opacity: 0.7;
- }
-
- .commit-preview {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 4px;
- }
-
- .commit-details {
- padding: 8px 12px;
- max-height: 200px;
- overflow-y: auto;
- }
-
- .commit-details pre {
- margin: 0;
- white-space: pre-wrap;
- word-break: break-word;
- }
-
- .commit-details.is-hidden {
- display: none;
- }
-
- .pushed-branch {
- color: #28a745;
- font-weight: 500;
- margin-left: 6px;
+ .commit-subject {
+ font-size: 13px;
+ color: #333;
+ flex-grow: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.commit-diff-button {
- padding: 3px 6px;
- border: 1px solid #ccc;
- border-radius: 3px;
- background-color: #f7f7f7;
- color: #24292e;
+ padding: 3px 8px;
+ border: none;
+ border-radius: 4px;
+ background-color: #0366d6;
+ color: white;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
+ display: block;
margin-left: auto;
}
.commit-diff-button:hover {
- background-color: #e7e7e7;
- border-color: #aaa;
+ background-color: #0256b4;
}
/* Tool call cards */
@@ -373,39 +399,65 @@
margin-top: 8px;
}
- /* Message type styles */
-
- .user .message-icon {
- background-color: #2196f3;
- }
-
- .agent .message-icon {
- background-color: #4caf50;
- }
-
- .tool .message-icon {
- background-color: #ff9800;
- }
-
- .error .message-icon {
- background-color: #f44336;
+ /* Error message specific styling */
+ .error .message-content {
+ background-color: #ffebee;
+ border-left: 3px solid #f44336;
}
.end-of-turn {
margin-bottom: 15px;
}
- .end-of-turn::after {
- content: "End of Turn";
- position: absolute;
- left: 15px;
- bottom: -10px;
- transform: translateX(-50%);
+ .end-of-turn-indicator {
+ display: block;
+ font-size: 11px;
+ color: #777;
+ padding: 2px 0;
+ margin-top: 8px;
+ text-align: right;
+ font-style: italic;
+ }
+
+ .user .end-of-turn-indicator {
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ /* Message info panel styling */
+ .message-info-panel {
+ margin-top: 8px;
+ padding: 8px;
+ background-color: rgba(0, 0, 0, 0.03);
+ border-radius: 6px;
+ font-size: 12px;
+ transition: all 0.2s ease;
+ border-left: 2px solid rgba(0, 0, 0, 0.1);
+ }
+
+ .user .message-info-panel {
+ background-color: rgba(255, 255, 255, 0.15);
+ border-left: 2px solid rgba(255, 255, 255, 0.2);
+ }
+
+ .info-row {
+ margin-bottom: 3px;
+ display: flex;
+ }
+
+ .info-label {
+ font-weight: bold;
+ margin-right: 5px;
+ min-width: 60px;
+ }
+
+ .info-value {
+ flex: 1;
+ }
+
+ .conversation-id {
+ font-family: monospace;
font-size: 10px;
- color: #666;
- background: #f0f0f0;
- padding: 1px 4px;
- border-radius: 3px;
+ word-break: break-all;
}
.markdown-content {
@@ -415,8 +467,44 @@
}
.markdown-content p {
- margin-block-start: 0.5em;
- margin-block-end: 0.5em;
+ margin-block-start: 0.3em;
+ margin-block-end: 0.3em;
+ }
+
+ .markdown-content p:first-child {
+ margin-block-start: 0;
+ }
+
+ .markdown-content p:last-child {
+ margin-block-end: 0;
+ }
+
+ /* Styling for markdown elements */
+ .markdown-content a {
+ color: inherit;
+ text-decoration: underline;
+ }
+
+ .user .markdown-content a {
+ color: #fff;
+ text-decoration: underline;
+ }
+
+ .markdown-content ul,
+ .markdown-content ol {
+ padding-left: 1.5em;
+ margin: 0.5em 0;
+ }
+
+ .markdown-content blockquote {
+ border-left: 3px solid rgba(0, 0, 0, 0.2);
+ padding-left: 1em;
+ margin-left: 0.5em;
+ font-style: italic;
+ }
+
+ .user .markdown-content blockquote {
+ border-left: 3px solid rgba(255, 255, 255, 0.4);
}
/* Mermaid diagram styling */
@@ -592,6 +680,26 @@
}
}
+ // Format duration from nanoseconds to a human-readable string
+ _formatDuration(nanoseconds: number | null | undefined): string {
+ if (!nanoseconds) return "0s";
+
+ const seconds = nanoseconds / 1e9;
+
+ if (seconds < 60) {
+ return `${seconds.toFixed(1)}s`;
+ } else if (seconds < 3600) {
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+ return `${minutes}min ${remainingSeconds.toFixed(0)}s`;
+ } else {
+ const hours = Math.floor(seconds / 3600);
+ const remainingSeconds = seconds % 3600;
+ const minutes = Math.floor(remainingSeconds / 60);
+ return `${hours}h ${minutes}min`;
+ }
+ }
+
showCommit(commitHash: string) {
this.dispatchEvent(
new CustomEvent("show-commit-diff", {
@@ -602,6 +710,11 @@
);
}
+ _toggleInfo(e: Event) {
+ e.stopPropagation();
+ this.showInfo = !this.showInfo;
+ }
+
copyToClipboard(text: string, event: Event) {
const element = event.currentTarget as HTMLElement;
const rect = element.getBoundingClientRect();
@@ -661,78 +774,190 @@
}
render() {
+ // Calculate if this is an end of turn message with no parent conversation ID
+ const isEndOfTurn =
+ this.message?.end_of_turn && !this.message?.parent_conversation_id;
+
return html`
<div
- class="message ${this.message?.type} ${this.message?.end_of_turn
+ class="message ${this.message?.type} ${isEndOfTurn
? "end-of-turn"
: ""}"
>
- ${this.previousMessage?.type != this.message?.type
- ? html`<div class="message-icon">
- ${this.message?.type.toUpperCase()[0]}
- </div>`
- : ""}
- <div class="message-content">
- <div class="message-header">
- <span class="message-type">${this.message?.type}</span>
- <span class="message-timestamp"
- >${this.formatTimestamp(this.message?.timestamp)}
- ${this.message?.elapsed
- ? html`(${(this.message?.elapsed / 1e9).toFixed(2)}s)`
- : ""}</span
- >
- ${this.message?.usage
- ? html` <span class="message-usage">
- <span title="Input tokens"
- >In:
- ${this.formatNumber(
- (this.message?.usage?.input_tokens || 0) +
- (this.message?.usage?.cache_read_input_tokens || 0) +
- (this.message?.usage?.cache_creation_input_tokens || 0),
- )}</span
+ <div class="message-container">
+ <!-- Left area (empty for simplicity) -->
+ <div class="message-metadata-left"></div>
+
+ <!-- Message bubble -->
+ <div class="message-bubble-container">
+ <div class="message-content">
+ <div class="message-text-container">
+ <div class="message-actions">
+ ${copyButton(this.message?.content)}
+ <button
+ class="info-icon"
+ title="Show message details"
+ @click=${this._toggleInfo}
>
- <span title="Output tokens"
- >Out:
- ${this.formatNumber(
- this.message?.usage?.output_tokens,
- )}</span
- >
- <span title="Message cost"
- >(${this.formatCurrency(
- this.message?.usage?.cost_usd,
- )})</span
- >
- </span>`
- : ""}
- </div>
- <div class="message-text-container">
- <div class="message-actions">
- ${copyButton(this.message?.content)}
- </div>
- ${this.message?.content
- ? html`
- <div class="message-text markdown-content">
- ${unsafeHTML(this.renderMarkdown(this.message?.content))}
- </div>
- `
- : ""}
- </div>
- <sketch-tool-calls
- .toolCalls=${this.message?.tool_calls}
- .open=${this.open}
- ></sketch-tool-calls>
- ${this.message?.commits
- ? html`
- <div class="commits-container">
- <div class="commits-header">
- ${this.message.commits.length} new
- commit${this.message.commits.length > 1 ? "s" : ""} detected
- </div>
- ${this.message.commits.map((commit) => {
- return html`
- <div class="commit-boxes-row">
- <div class="commit-box">
- <div class="commit-preview">
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="16"
+ height="16"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <circle cx="12" cy="12" r="10"></circle>
+ <line x1="12" y1="16" x2="12" y2="12"></line>
+ <line x1="12" y1="8" x2="12.01" y2="8"></line>
+ </svg>
+ </button>
+ </div>
+ ${this.message?.content
+ ? html`
+ <div class="message-text markdown-content">
+ ${unsafeHTML(
+ this.renderMarkdown(this.message?.content),
+ )}
+ </div>
+ `
+ : ""}
+
+ <!-- End of turn indicator inside the bubble -->
+ ${isEndOfTurn && this.message?.elapsed
+ ? html`
+ <div class="end-of-turn-indicator">
+ end of turn
+ (${this._formatDuration(this.message?.elapsed)})
+ </div>
+ `
+ : ""}
+
+ <!-- Info panel that can be toggled -->
+ ${this.showInfo
+ ? html`
+ <div class="message-info-panel">
+ <div class="info-row">
+ <span class="info-label">Type:</span>
+ <span class="info-value">${this.message?.type}</span>
+ </div>
+ <div class="info-row">
+ <span class="info-label">Time:</span>
+ <span class="info-value"
+ >${this.formatTimestamp(
+ this.message?.timestamp,
+ "",
+ )}</span
+ >
+ </div>
+ ${this.message?.elapsed
+ ? html`
+ <div class="info-row">
+ <span class="info-label">Duration:</span>
+ <span class="info-value"
+ >${this._formatDuration(
+ this.message?.elapsed,
+ )}</span
+ >
+ </div>
+ `
+ : ""}
+ ${this.message?.usage
+ ? html`
+ <div class="info-row">
+ <span class="info-label">Tokens:</span>
+ <span class="info-value">
+ ${this.message?.usage
+ ? html`
+ <div>
+ Input:
+ ${this.formatNumber(
+ this.message?.usage?.input_tokens ||
+ 0,
+ )}
+ </div>
+ ${this.message?.usage
+ ?.cache_creation_input_tokens
+ ? html`
+ <div>
+ Cache creation:
+ ${this.formatNumber(
+ this.message?.usage
+ ?.cache_creation_input_tokens,
+ )}
+ </div>
+ `
+ : ""}
+ ${this.message?.usage
+ ?.cache_read_input_tokens
+ ? html`
+ <div>
+ Cache read:
+ ${this.formatNumber(
+ this.message?.usage
+ ?.cache_read_input_tokens,
+ )}
+ </div>
+ `
+ : ""}
+ <div>
+ Output:
+ ${this.formatNumber(
+ this.message?.usage?.output_tokens,
+ )}
+ </div>
+ <div>
+ Cost:
+ ${this.formatCurrency(
+ this.message?.usage?.cost_usd,
+ )}
+ </div>
+ `
+ : "N/A"}
+ </span>
+ </div>
+ `
+ : ""}
+ ${this.message?.conversation_id
+ ? html`
+ <div class="info-row">
+ <span class="info-label">Conversation ID:</span>
+ <span class="info-value conversation-id"
+ >${this.message?.conversation_id}</span
+ >
+ </div>
+ `
+ : ""}
+ </div>
+ `
+ : ""}
+ </div>
+
+ <!-- Tool calls - only shown for agent messages -->
+ ${this.message?.type === "agent"
+ ? html`
+ <sketch-tool-calls
+ .toolCalls=${this.message?.tool_calls}
+ .open=${this.open}
+ ></sketch-tool-calls>
+ `
+ : ""}
+
+ <!-- Commits section (redesigned as bubbles) -->
+ ${this.message?.commits
+ ? html`
+ <div class="commits-container">
+ <div class="commit-notification">
+ ${this.message.commits.length} new
+ commit${this.message.commits.length > 1 ? "s" : ""}
+ detected
+ </div>
+ ${this.message.commits.map((commit) => {
+ return html`
+ <div class="commit-card">
<span
class="commit-hash"
title="Click to copy: ${commit.hash}"
@@ -746,18 +971,16 @@
</span>
${commit.pushed_branch
? html`
- <span class="branch-wrapper">
- (<span
- class="commit-branch pushed-branch"
- title="Click to copy: ${commit.pushed_branch}"
- @click=${(e) =>
- this.copyToClipboard(
- commit.pushed_branch,
- e,
- )}
- >${commit.pushed_branch}</span
- >)
- </span>
+ <span
+ class="commit-branch pushed-branch"
+ title="Click to copy: ${commit.pushed_branch}"
+ @click=${(e) =>
+ this.copyToClipboard(
+ commit.pushed_branch,
+ e,
+ )}
+ >${commit.pushed_branch}</span
+ >
`
: ``}
<span class="commit-subject"
@@ -770,16 +993,16 @@
View Diff
</button>
</div>
- <div class="commit-details is-hidden">
- <pre>${commit.body}</pre>
- </div>
- </div>
- </div>
- `;
- })}
- </div>
- `
- : ""}
+ `;
+ })}
+ </div>
+ `
+ : ""}
+ </div>
+ </div>
+
+ <!-- Right side (empty for consistency) -->
+ <div class="message-metadata-right"></div>
</div>
</div>
`;
@@ -787,11 +1010,39 @@
}
function copyButton(textToCopy: string) {
- // Add click event listener to handle copying
- const buttonClass = "copy-button";
- const buttonContent = "Copy";
- const successContent = "Copied!";
- const failureContent = "Failed";
+ // Use an icon of overlapping rectangles for copy
+ const buttonClass = "copy-icon";
+
+ // SVG for copy icon (two overlapping rectangles)
+ const copyIcon = html`<svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="16"
+ height="16"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
+ </svg>`;
+
+ // SVG for success check mark
+ const successIcon = html`<svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="16"
+ height="16"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <path d="M20 6L9 17l-5-5"></path>
+ </svg>`;
const ret = html`<button
class="${buttonClass}"
@@ -799,24 +1050,27 @@
@click=${(e: Event) => {
e.stopPropagation();
const copyButton = e.currentTarget as HTMLButtonElement;
+ const originalInnerHTML = copyButton.innerHTML;
navigator.clipboard
.writeText(textToCopy)
.then(() => {
- copyButton.textContent = successContent;
+ copyButton.innerHTML = "";
+ const successElement = document.createElement("div");
+ copyButton.appendChild(successElement);
+ render(successIcon, successElement);
setTimeout(() => {
- copyButton.textContent = buttonContent;
+ copyButton.innerHTML = originalInnerHTML;
}, 2000);
})
.catch((err) => {
console.error("Failed to copy text: ", err);
- copyButton.textContent = failureContent;
setTimeout(() => {
- copyButton.textContent = buttonContent;
+ copyButton.innerHTML = originalInnerHTML;
}, 2000);
});
}}
>
- ${buttonContent}
+ ${copyIcon}
</button>`;
return ret;
diff --git a/webui/src/web-components/sketch-timeline.ts b/webui/src/web-components/sketch-timeline.ts
index 686ea01..7fbe83a 100644
--- a/webui/src/web-components/sketch-timeline.ts
+++ b/webui/src/web-components/sketch-timeline.ts
@@ -11,6 +11,16 @@
@property({ attribute: false })
messages: AgentMessage[] = [];
+ // Active state properties to show thinking indicator
+ @property({ attribute: false })
+ agentState: string | null = null;
+
+ @property({ attribute: false })
+ llmCalls: number = 0;
+
+ @property({ attribute: false })
+ toolCalls: string[] = [];
+
// Track if we should scroll to the bottom
@state()
private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
@@ -38,30 +48,20 @@
.timeline-container {
width: 100%;
position: relative;
+ max-width: 100%;
+ margin: 0 auto;
+ padding: 0 15px;
+ box-sizing: border-box;
}
- /* Timeline styles that should remain unchanged */
+ /* Chat-like timeline styles */
.timeline {
position: relative;
margin: 10px 0;
scroll-behavior: smooth;
}
- .timeline::before {
- content: "";
- position: absolute;
- top: 0;
- bottom: 0;
- left: 15px;
- width: 2px;
- background: #e0e0e0;
- border-radius: 1px;
- }
-
- /* Hide the timeline vertical line when there are no messages */
- .timeline.empty::before {
- display: none;
- }
+ /* Remove the vertical timeline line */
#scroll-container {
overflow: auto;
@@ -114,6 +114,64 @@
font-size: 1rem;
text-align: left;
}
+
+ /* Thinking indicator styles */
+ .thinking-indicator {
+ padding-left: 85px;
+ margin-top: 5px;
+ margin-bottom: 15px;
+ display: flex;
+ }
+
+ .thinking-bubble {
+ background-color: #f1f1f1;
+ border-radius: 15px;
+ padding: 10px 15px;
+ max-width: 80px;
+ color: black;
+ position: relative;
+ border-bottom-left-radius: 5px;
+ }
+
+ .thinking-dots {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ height: 14px;
+ }
+
+ .dot {
+ width: 6px;
+ height: 6px;
+ background-color: #888;
+ border-radius: 50%;
+ opacity: 0.6;
+ }
+
+ .dot:nth-child(1) {
+ animation: pulse 1.5s infinite ease-in-out;
+ }
+
+ .dot:nth-child(2) {
+ animation: pulse 1.5s infinite ease-in-out 0.3s;
+ }
+
+ .dot:nth-child(3) {
+ animation: pulse 1.5s infinite ease-in-out 0.6s;
+ }
+
+ @keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 0.4;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.2);
+ }
+ }
`;
constructor() {
@@ -257,6 +315,9 @@
}
// Otherwise render the regular timeline with messages
+ const isThinking =
+ this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
+
return html`
<div id="scroll-container">
<div class="timeline-container">
@@ -271,6 +332,19 @@
.open=${false}
></sketch-timeline-message>`;
})}
+ ${isThinking
+ ? html`
+ <div class="thinking-indicator">
+ <div class="thinking-bubble">
+ <div class="thinking-dots">
+ <div class="dot"></div>
+ <div class="dot"></div>
+ <div class="dot"></div>
+ </div>
+ </div>
+ </div>
+ `
+ : ""}
</div>
</div>
<div
diff --git a/webui/src/web-components/sketch-tool-calls.ts b/webui/src/web-components/sketch-tool-calls.ts
index 4f49df9..14cb218 100644
--- a/webui/src/web-components/sketch-tool-calls.ts
+++ b/webui/src/web-components/sketch-tool-calls.ts
@@ -1,5 +1,5 @@
import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
+import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { ToolCall } from "../types";
import "./sketch-tool-card";
@@ -12,24 +12,27 @@
@property()
open: boolean = false;
+ @state()
+ expanded: boolean = false;
+
static styles = css`
/* Tool calls container styles */
.tool-calls-container {
- /* Container for all tool calls */
- }
-
- /* Header for tool calls section */
- .tool-calls-header {
- /* Empty header - just small spacing */
+ margin-top: 8px;
+ padding-top: 4px;
}
/* Card container */
.tool-call-card {
display: flex;
flex-direction: column;
- background-color: white;
+ background-color: rgba(255, 255, 255, 0.6);
+ border-radius: 6px;
+ margin-bottom: 6px;
overflow: hidden;
cursor: pointer;
+ border-left: 2px solid rgba(0, 0, 0, 0.1);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
/* Status indicators for tool calls */
@@ -52,6 +55,10 @@
transform: rotate(360deg);
}
}
+
+ .tool-call-cards-container {
+ display: block;
+ }
`;
constructor() {
@@ -124,23 +131,24 @@
}
render() {
+ if (!this.toolCalls || this.toolCalls.length === 0) {
+ return html``;
+ }
+
return html`<div class="tool-calls-container">
- <div class="tool-calls-header"></div>
<div class="tool-call-cards-container">
- ${this.toolCalls
- ? repeat(this.toolCalls, this.toolUseKey, (toolCall, idx) => {
- let lastCall = false;
- if (idx == this.toolCalls?.length - 1) {
- lastCall = true;
- }
- return html`<div
- id="${toolCall.tool_call_id}"
- class="tool-call-card ${toolCall.name}"
- >
- ${this.cardForToolCall(toolCall, lastCall && this.open)}
- </div>`;
- })
- : ""}
+ ${repeat(this.toolCalls, this.toolUseKey, (toolCall, idx) => {
+ let lastCall = false;
+ if (idx == this.toolCalls?.length - 1) {
+ lastCall = true;
+ }
+ return html`<div
+ id="${toolCall.tool_call_id}"
+ class="tool-call-card ${toolCall.name}"
+ >
+ ${this.cardForToolCall(toolCall, lastCall && this.open)}
+ </div>`;
+ })}
</div>
</div>`;
}
diff --git a/webui/src/web-components/sketch-tool-card.ts b/webui/src/web-components/sketch-tool-card.ts
index 80dd2e9..d03b29d 100644
--- a/webui/src/web-components/sketch-tool-card.ts
+++ b/webui/src/web-components/sketch-tool-card.ts
@@ -1,6 +1,6 @@
import { css, html, LitElement } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
-import { customElement, property } from "lit/decorators.js";
+import { customElement, property, state } from "lit/decorators.js";
import { ToolCall, MultipleChoiceOption, MultipleChoiceParams } from "../types";
import { marked, MarkedOptions } from "marked";
@@ -29,23 +29,100 @@
@property()
open: boolean;
+ @state()
+ detailsVisible: boolean = false;
+
static styles = css`
.tool-call {
display: flex;
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .tool-row {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 6px 8px 6px 12px;
align-items: center;
- gap: 8px;
+ gap: 8px; /* Reduce gap slightly to accommodate longer tool names */
+ cursor: pointer;
+ border-radius: 4px;
+ position: relative;
+ overflow: hidden; /* Changed to hidden to prevent horizontal scrolling */
+ }
+
+ .tool-row:hover {
+ background-color: rgba(0, 0, 0, 0.02);
+ }
+
+ .tool-name {
+ font-family: monospace;
+ font-weight: 500;
+ color: #444;
+ background-color: rgba(0, 0, 0, 0.05);
+ border-radius: 3px;
+ padding: 2px 6px;
+ flex-shrink: 0;
+ min-width: 45px;
+ /* Remove max-width to prevent truncation */
+ font-size: 12px;
+ text-align: center;
+ /* Remove overflow/ellipsis to ensure names are fully visible */
white-space: nowrap;
}
+ .tool-success {
+ color: #5cb85c;
+ font-size: 14px;
+ }
+
+ .tool-error {
+ color: #d9534f;
+ font-size: 14px;
+ }
+
+ .tool-pending {
+ color: #f0ad4e;
+ font-size: 14px;
+ }
+
+ .summary-text {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ flex-grow: 1;
+ flex-shrink: 1;
+ color: #444;
+ font-family: monospace;
+ font-size: 12px;
+ padding: 0 4px;
+ min-width: 50px;
+ max-width: calc(
+ 100% - 250px
+ ); /* More space for tool-name and tool-status */
+ display: inline-block; /* Ensure proper truncation */
+ }
+
+ .tool-status {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-left: auto;
+ flex-shrink: 0;
+ min-width: 120px; /* Increased width to prevent cutoff */
+ justify-content: flex-end;
+ padding-right: 8px;
+ }
+
.tool-call-status {
- margin-right: 4px;
- text-align: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
.tool-call-status.spinner {
animation: spin 1s infinite linear;
- display: inline-block;
- width: 1em;
}
@keyframes spin {
@@ -57,89 +134,61 @@
}
}
- .title {
- font-style: italic;
+ .elapsed {
+ font-size: 11px;
+ color: #777;
+ white-space: nowrap;
+ min-width: 40px;
+ text-align: right;
+ }
+
+ .tool-details {
+ padding: 8px;
+ background-color: rgba(0, 0, 0, 0.02);
+ margin-top: 1px;
+ border-top: 1px solid rgba(0, 0, 0, 0.05);
+ display: none;
+ font-family: monospace;
+ font-size: 12px;
+ color: #333;
+ border-radius: 0 0 4px 4px;
+ max-width: 100%;
+ width: 100%;
+ box-sizing: border-box;
+ overflow: hidden; /* Hide overflow at container level */
+ }
+
+ .tool-details.visible {
+ display: block;
+ }
+
+ .expand-indicator {
+ color: #aaa;
+ font-size: 10px;
+ width: 12px;
+ display: inline-block;
+ text-align: center;
}
.cancel-button {
- background: rgb(76, 175, 80);
- color: white;
- border: none;
- padding: 4px 10px;
- border-radius: 4px;
cursor: pointer;
- font-size: 12px;
- margin: 5px;
+ color: white;
+ background-color: #d9534f;
+ border: none;
+ border-radius: 3px;
+ font-size: 11px;
+ padding: 2px 6px;
+ white-space: nowrap;
+ min-width: 50px;
}
.cancel-button:hover {
- background: rgb(200, 35, 51) !important;
+ background-color: #c9302c;
}
- .codereview-OK {
- color: green;
- }
-
- details {
- border-radius: 4px;
- padding: 0.25em;
- margin: 0.25em;
- display: flex;
- flex-direction: column;
- align-items: start;
- }
-
- details summary {
- list-style: none;
- &::before {
- cursor: hand;
- font-family: monospace;
- content: "+";
- color: white;
- background-color: darkgray;
- border-radius: 1em;
- padding-left: 0.5em;
- margin: 0.25em;
- min-width: 1em;
- }
- [open] &::before {
- content: "-";
- }
- }
-
- details summary:hover {
- list-style: none;
- &::before {
- background-color: gray;
- }
- }
- summary {
- display: flex;
- flex-direction: row;
- flex-wrap: nowrap;
- justify-content: flex-start;
- align-items: baseline;
- }
-
- summary .tool-name {
- font-family: monospace;
- color: white;
- background: rgb(124 145 160);
- border-radius: 4px;
- padding: 0.25em;
- margin: 0.25em;
- white-space: pre;
- }
-
- .summary-text {
- padding: 0.25em;
- display: flex;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- details[open] .summary-text {
- /*display: none;*/
+ .cancel-button[disabled] {
+ background-color: #999;
+ cursor: not-allowed;
}
.tool-error-message {
@@ -147,11 +196,8 @@
color: #aa0909;
}
- .elapsed {
- font-size: 10px;
- color: #888;
- font-style: italic;
- margin-left: 3px;
+ .codereview-OK {
+ color: green;
}
`;
@@ -194,16 +240,28 @@
}
};
- render() {
- const toolCallStatus = this.toolCall?.result_message
- ? this.toolCall?.result_message.tool_error
- ? html`🙈
- <span class="tool-error-message"
- >${this.toolCall?.result_message.tool_result}</span
- >`
- : ""
- : "⏳";
+ _toggleDetails(e: Event) {
+ e.stopPropagation();
+ this.detailsVisible = !this.detailsVisible;
+ }
+ render() {
+ // Determine the status indicator based on the tool call result
+ let statusIcon;
+ if (!this.toolCall?.result_message) {
+ // Pending status with spinner
+ statusIcon = html`<span class="tool-call-status spinner tool-pending"
+ >⏳</span
+ >`;
+ } else if (this.toolCall?.result_message.tool_error) {
+ // Error status
+ statusIcon = html`<span class="tool-call-status tool-error">⚠️</span>`;
+ } else {
+ // Success status
+ statusIcon = html`<span class="tool-call-status tool-success">✓</span>`;
+ }
+
+ // Cancel button for pending operations
const cancelButton = this.toolCall?.result_message
? ""
: html`<button
@@ -218,32 +276,29 @@
Cancel
</button>`;
- const status = html`<span
- class="tool-call-status ${this.toolCall?.result_message ? "" : "spinner"}"
- >${toolCallStatus}</span
- >`;
-
- const elapsed = html`${this.toolCall?.result_message?.elapsed
+ // Elapsed time display
+ const elapsed = this.toolCall?.result_message?.elapsed
? html`<span class="elapsed"
- >${(this.toolCall?.result_message?.elapsed / 1e9).toFixed(2)}s
- elapsed</span
+ >${(this.toolCall?.result_message?.elapsed / 1e9).toFixed(1)}s</span
>`
- : ""}`;
+ : html`<span class="elapsed"></span>`; // Empty span to maintain layout
- const ret = html`<div class="tool-call">
- <details ?open=${this.open}>
- <summary>
- <span class="tool-name">${this.toolCall?.name}</span>
- <span class="summary-text"><slot name="summary"></slot></span>
- ${status} ${cancelButton} ${elapsed}
- </summary>
+ // Initialize details visibility based on open property
+ if (this.open && !this.detailsVisible) {
+ this.detailsVisible = true;
+ }
+
+ return html`<div class="tool-call">
+ <div class="tool-row" @click=${this._toggleDetails}>
+ <span class="tool-name">${this.toolCall?.name}</span>
+ <span class="summary-text"><slot name="summary"></slot></span>
+ <div class="tool-status">${statusIcon} ${elapsed} ${cancelButton}</div>
+ </div>
+ <div class="tool-details ${this.detailsVisible ? "visible" : ""}">
<slot name="input"></slot>
<slot name="result"></slot>
- </details>
- </div> `;
- if (true) {
- return ret;
- }
+ </div>
+ </div>`;
}
}
@@ -261,24 +316,57 @@
color: black;
padding: 0.5em;
border-radius: 4px;
+ white-space: pre-wrap; /* Always wrap long lines */
+ word-break: break-word; /* Use break-word for a more readable break */
+ max-width: 100%;
+ width: 100%;
+ box-sizing: border-box;
+ overflow-wrap: break-word; /* Additional property for better wrapping */
}
.summary-text {
overflow: hidden;
text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
font-family: monospace;
}
+
+ .command-wrapper {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 100%;
+ }
.input {
display: flex;
+ width: 100%;
+ flex-direction: column; /* Change to column layout */
}
.input pre {
width: 100%;
margin-bottom: 0;
border-radius: 4px 4px 0 0;
+ box-sizing: border-box; /* Include padding in width calculation */
}
.result pre {
margin-top: 0;
color: #555;
border-radius: 0 0 4px 4px;
+ width: 100%; /* Ensure it uses full width */
+ box-sizing: border-box; /* Include padding in width calculation */
+ overflow-wrap: break-word; /* Ensure long words wrap */
+ }
+
+ /* Add a special class for long output that should be scrollable on hover */
+ .result pre.scrollable-on-hover {
+ max-height: 300px;
+ overflow-y: auto;
+ }
+
+ /* Container for tool call results with proper text wrapping */
+ .tool-call-result-container {
+ width: 100%;
+ position: relative;
}
.background-badge {
display: inline-block;
@@ -292,8 +380,11 @@
vertical-align: middle;
}
.command-wrapper {
- display: flex;
- align-items: center;
+ display: inline-block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
`;
@@ -318,19 +409,23 @@
<sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
<span slot="summary" class="summary-text">
<div class="command-wrapper">
- 🖥️ ${backgroundIcon}${inputData?.command}
+ ${backgroundIcon}${inputData?.command}
</div>
</span>
<div slot="input" class="input">
- <pre>🖥️ ${backgroundIcon}${inputData?.command}</pre>
+ <div class="tool-call-result-container">
+ <pre>${backgroundIcon}${inputData?.command}</pre>
+ </div>
</div>
${
this.toolCall?.result_message
? html` ${this.toolCall?.result_message.tool_result
? html`<div slot="result" class="result">
- <pre class="tool-call-result">
+ <div class="tool-call-result-container">
+ <pre class="tool-call-result">
${this.toolCall?.result_message.tool_result}</pre
- >
+ >
+ </div>
</div>`
: ""}`
: ""
@@ -587,12 +682,15 @@
render() {
const inputData = JSON.parse(this.toolCall?.input || "{}");
return html`
- <span class="summary-text">
- Setting title to
- <b>${inputData.title}</b>
- and branch to
- <pre>sketch/${inputData.branch_name}</pre>
- </span>
+ <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
+ <span slot="summary" class="summary-text">
+ Title: "${inputData.title}" | Branch: sketch/${inputData.branch_name}
+ </span>
+ <div slot="input">
+ <div>Set title to: <b>${inputData.title}</b></div>
+ <div>Set branch to: <code>sketch/${inputData.branch_name}</code></div>
+ </div>
+ </sketch-tool-card>
`;
}
}
diff --git a/webui/vite.config.mts b/webui/vite.config.mts
index 74508a5..2d99b63 100644
--- a/webui/vite.config.mts
+++ b/webui/vite.config.mts
@@ -11,5 +11,29 @@
include: ["./src/**/*.ts"],
presets: [presets.lit],
}),
+ // Custom plugin for handling the root path redirect
+ {
+ name: "configure-server",
+ configureServer(server) {
+ server.middlewares.use((req, res, next) => {
+ if (req.url === "/") {
+ res.writeHead(302, {
+ Location: "/src/web-components/demo/index.html",
+ });
+ res.end();
+ return;
+ }
+ next();
+ });
+ },
+ },
],
+ server: {
+ // Define a middleware to handle the root path redirects
+ middlewareMode: false,
+ fs: {
+ // Allow serving files from these directories
+ allow: ["/app/webui"],
+ },
+ },
});