blob: 221f3b2df8c437761b3705bcf12dbd586a5f4bcf [file] [log] [blame]
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ConnectionStatus, DataManager } from "../data";
import { AgentMessage, GitCommit, State } from "../types";
import { aggregateAgentMessages } from "./aggregateAgentMessages";
import "./sketch-charts";
import "./sketch-chat-input";
import "./sketch-container-status";
import "./sketch-diff-view";
import { SketchDiffView } from "./sketch-diff-view";
import "./sketch-network-status";
import "./sketch-call-status";
import "./sketch-terminal";
import "./sketch-timeline";
import "./sketch-view-mode-select";
import "./sketch-restart-modal";
import { createRef, ref } from "lit/directives/ref.js";
type ViewMode = "chat" | "diff" | "charts" | "terminal";
@customElement("sketch-app-shell")
export class SketchAppShell extends LitElement {
// Current view mode (chat, diff, charts, terminal)
@state()
viewMode: "chat" | "diff" | "charts" | "terminal" = "chat";
// Current commit hash for diff view
@state()
currentCommitHash: string = "";
// Last commit information
@state()
lastCommit: { hash: string; pushedBranch?: string } | null = 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;
left: 50%;
transform: translateX(-50%);
background: rgba(40, 167, 69, 0.9);
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-family: system-ui, sans-serif;
animation: fadeInOut 2s ease;
pointer-events: none;
}
@keyframes fadeInOut {
0% {
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.commit-branch-indicator {
color: #28a745;
}
.commit-hash-indicator {
color: #0366d6;
}
:host {
display: block;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
color: #333;
line-height: 1.4;
height: 100vh;
width: 100%;
position: relative;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
/* Top banner with combined elements */
#top-banner {
display: flex;
align-self: stretch;
justify-content: space-between;
align-items: center;
padding: 0 20px;
margin-bottom: 0;
border-bottom: 1px solid #eee;
gap: 20px;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%;
height: 48px;
padding-right: 30px; /* Extra padding on the right to prevent elements from hitting the edge */
}
/* View mode container styles - mirroring timeline.css structure */
#view-container {
align-self: stretch;
overflow-y: auto;
flex: 1;
}
#view-container-inner {
max-width: 1200px;
margin: 0 auto;
position: relative;
padding-bottom: 10px;
padding-top: 10px;
}
#chat-input {
align-self: flex-end;
width: 100%;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
}
.banner-title {
font-size: 18px;
font-weight: 600;
margin: 0;
min-width: 6em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-title {
margin: 0;
padding: 0;
color: rgba(82, 82, 82, 0.85);
font-size: 14px;
font-weight: normal;
font-style: italic;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Allow the container to expand to full width in diff mode */
#view-container-inner.diff-active {
max-width: 100%;
width: 100%;
}
/* Individual view styles */
.chat-view,
.diff-view,
.chart-view,
.terminal-view {
display: none; /* Hidden by default */
width: 100%;
}
/* Active view styles - these will be applied via JavaScript */
.view-active {
display: flex;
flex-direction: column;
}
.title-container {
display: flex;
flex-direction: column;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 30%;
padding: 6px 0;
}
.refresh-control {
display: flex;
align-items: center;
margin-bottom: 0;
flex-wrap: nowrap;
white-space: nowrap;
flex-shrink: 0;
gap: 15px;
padding-left: 15px;
margin-right: 50px;
}
.restart-button {
background: #2196f3;
color: white;
border: none;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-right: 5px;
}
.restart-button:hover {
background-color: #0b7dda;
}
.restart-button:disabled {
background-color: #ccc;
cursor: not-allowed;
opacity: 0.6;
}
.refresh-button {
background: #4caf50;
color: white;
border: none;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-right: 5px;
}
.stop-button:hover {
background-color: #c82333 !important;
}
.poll-updates {
display: flex;
align-items: center;
font-size: 12px;
margin-right: 10px;
}
.notifications-toggle {
display: flex;
align-items: center;
font-size: 12px;
margin-right: 10px;
cursor: pointer;
}
.bell-icon {
width: 20px;
height: 20px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bell-disabled::before {
content: "";
position: absolute;
width: 2px;
height: 24px;
background-color: #dc3545;
transform: rotate(45deg);
transform-origin: center center;
}
`;
// Header bar: Network connection status details
@property()
connectionStatus: ConnectionStatus = "disconnected";
// Track if the last commit info has been copied
@state()
lastCommitCopied: boolean = false;
// Track notification preferences
@state()
notificationsEnabled: boolean = false;
// Track if the window is focused to control notifications
@state()
private _windowFocused: boolean = document.hasFocus();
@property()
connectionErrorMessage: string = "";
@property()
messageStatus: string = "";
// Chat messages
@property({ attribute: false })
messages: AgentMessage[] = [];
@property()
set title(value: string) {
const oldValue = this._title;
this._title = value;
this.requestUpdate("title", oldValue);
// Update document title when title property changes
this.updateDocumentTitle();
}
get title(): string {
return this._title;
}
private _title: string = "";
private dataManager = new DataManager();
@property({ attribute: false })
containerState: State = {
title: "",
os: "",
message_count: 0,
hostname: "",
working_dir: "",
initial_commit: "",
outstanding_llm_calls: 0,
outstanding_tool_calls: [],
session_id: "",
ssh_available: false,
ssh_error: "",
in_container: false,
first_message_index: 0,
};
@state()
private restartModalOpen = false;
// Mutation observer to detect when new messages are added
private mutationObserver: MutationObserver | null = null;
constructor() {
super();
// Binding methods to this
this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
this._handlePopState = this._handlePopState.bind(this);
this._handleStopClick = this._handleStopClick.bind(this);
this._handleNotificationsToggle =
this._handleNotificationsToggle.bind(this);
this._handleWindowFocus = this._handleWindowFocus.bind(this);
this._handleWindowBlur = this._handleWindowBlur.bind(this);
// Load notification preference from localStorage
try {
const savedPref = localStorage.getItem("sketch-notifications-enabled");
if (savedPref !== null) {
this.notificationsEnabled = savedPref === "true";
}
} catch (error) {
console.error("Error loading notification preference:", error);
}
}
// See https://lit.dev/docs/components/lifecycle/
connectedCallback() {
super.connectedCallback();
// Initialize client-side nav history.
const url = new URL(window.location.href);
const mode = url.searchParams.get("view") || "chat";
window.history.replaceState({ mode }, "", url.toString());
this.toggleViewMode(mode as ViewMode, false);
// Add popstate event listener to handle browser back/forward navigation
window.addEventListener("popstate", this._handlePopState);
// Add event listeners
window.addEventListener("view-mode-select", this._handleViewModeSelect);
window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
// Add window focus/blur listeners for controlling notifications
window.addEventListener("focus", this._handleWindowFocus);
window.addEventListener("blur", this._handleWindowBlur);
// register event listeners
this.dataManager.addEventListener(
"dataChanged",
this.handleDataChanged.bind(this),
);
this.dataManager.addEventListener(
"connectionStatusChanged",
this.handleConnectionStatusChanged.bind(this),
);
// Set initial document title
this.updateDocumentTitle();
// Initialize the data manager
this.dataManager.initialize();
// Process existing messages for commit info
if (this.messages && this.messages.length > 0) {
this.updateLastCommitInfo(this.messages);
}
}
// See https://lit.dev/docs/components/lifecycle/
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("popstate", this._handlePopState);
// Remove event listeners
window.removeEventListener("view-mode-select", this._handleViewModeSelect);
window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
window.removeEventListener("focus", this._handleWindowFocus);
window.removeEventListener("blur", this._handleWindowBlur);
// unregister data manager event listeners
this.dataManager.removeEventListener(
"dataChanged",
this.handleDataChanged.bind(this),
);
this.dataManager.removeEventListener(
"connectionStatusChanged",
this.handleConnectionStatusChanged.bind(this),
);
// Disconnect mutation observer if it exists
if (this.mutationObserver) {
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
}
updateUrlForViewMode(mode: "chat" | "diff" | "charts" | "terminal"): void {
// Get the current URL without search parameters
const url = new URL(window.location.href);
// Clear existing parameters
url.search = "";
// Only add view parameter if not in default chat view
if (mode !== "chat") {
url.searchParams.set("view", mode);
const diffView = this.shadowRoot?.querySelector(
".diff-view",
) as SketchDiffView;
// If in diff view and there's a commit hash, include that too
if (mode === "diff" && diffView.commitHash) {
url.searchParams.set("commit", diffView.commitHash);
}
}
// Update the browser history without reloading the page
window.history.pushState({ mode }, "", url.toString());
}
private _handlePopState(event: PopStateEvent) {
if (event.state && event.state.mode) {
this.toggleViewMode(event.state.mode, false);
} else {
this.toggleViewMode("chat", false);
}
}
/**
* Handle view mode selection event
*/
private _handleViewModeSelect(event: CustomEvent) {
const mode = event.detail.mode as "chat" | "diff" | "charts" | "terminal";
this.toggleViewMode(mode, true);
}
/**
* Handle show commit diff event
*/
private _handleShowCommitDiff(event: CustomEvent) {
const { commitHash } = event.detail;
if (commitHash) {
this.showCommitDiff(commitHash);
}
}
/**
* Listen for commit diff event
* @param commitHash The commit hash to show diff for
*/
private showCommitDiff(commitHash: string): void {
// Store the commit hash
this.currentCommitHash = commitHash;
// Switch to diff view
this.toggleViewMode("diff", true);
// Wait for DOM update to complete
this.updateComplete.then(() => {
// Get the diff view component
const diffView = this.shadowRoot?.querySelector("sketch-diff-view");
if (diffView) {
// Call the showCommitDiff method
(diffView as any).showCommitDiff(commitHash);
}
});
}
/**
* Toggle between different view modes: chat, diff, charts, terminal
*/
private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
// Don't do anything if the mode is already active
if (this.viewMode === mode) return;
// Update the view mode
this.viewMode = mode;
if (updateHistory) {
// Update URL with the current view mode
this.updateUrlForViewMode(mode);
}
// Wait for DOM update to complete
this.updateComplete.then(() => {
// Update active view
const viewContainerInner = this.shadowRoot?.querySelector(
"#view-container-inner",
);
const chatView = this.shadowRoot?.querySelector(".chat-view");
const diffView = this.shadowRoot?.querySelector(".diff-view");
const chartView = this.shadowRoot?.querySelector(".chart-view");
const terminalView = this.shadowRoot?.querySelector(".terminal-view");
// Remove active class from all views
chatView?.classList.remove("view-active");
diffView?.classList.remove("view-active");
chartView?.classList.remove("view-active");
terminalView?.classList.remove("view-active");
// Add/remove diff-active class on view container
if (mode === "diff") {
viewContainerInner?.classList.add("diff-active");
} else {
viewContainerInner?.classList.remove("diff-active");
}
// Add active class to the selected view
switch (mode) {
case "chat":
chatView?.classList.add("view-active");
break;
case "diff":
diffView?.classList.add("view-active");
// Load diff content if we have a diff view
const diffViewComp =
this.shadowRoot?.querySelector("sketch-diff-view");
if (diffViewComp && this.currentCommitHash) {
(diffViewComp as any).showCommitDiff(this.currentCommitHash);
} else if (diffViewComp) {
(diffViewComp as any).loadDiffContent();
}
break;
case "charts":
chartView?.classList.add("view-active");
break;
case "terminal":
terminalView?.classList.add("view-active");
break;
}
// Update view mode buttons
const viewModeSelect = this.shadowRoot?.querySelector(
"sketch-view-mode-select",
);
if (viewModeSelect) {
const event = new CustomEvent("update-active-mode", {
detail: { mode },
bubbles: true,
composed: true,
});
viewModeSelect.dispatchEvent(event);
}
// FIXME: This is a hack to get vega chart in sketch-charts.ts to work properly
// When the chart is in the background, its container has a width of 0, so vega
// renders width 0 and only changes that width on a resize event.
// See https://github.com/vega/react-vega/issues/85#issuecomment-1826421132
window.dispatchEvent(new Event("resize"));
});
}
/**
* Updates the document title based on current title and connection status
*/
private updateDocumentTitle(): void {
let docTitle = `sk: ${this.title || "untitled"}`;
// Add red circle emoji if disconnected
if (this.connectionStatus === "disconnected") {
docTitle += " 🔴";
}
document.title = docTitle;
}
// Check and request notification permission if needed
private async checkNotificationPermission(): Promise<boolean> {
// Check if the Notification API is supported
if (!("Notification" in window)) {
console.log("This browser does not support notifications");
return false;
}
// Check if permission is already granted
if (Notification.permission === "granted") {
return true;
}
// If permission is not denied, request it
if (Notification.permission !== "denied") {
const permission = await Notification.requestPermission();
return permission === "granted";
}
return false;
}
// Handle notifications toggle click
private _handleNotificationsToggle(): void {
this.notificationsEnabled = !this.notificationsEnabled;
// If enabling notifications, check permissions
if (this.notificationsEnabled) {
this.checkNotificationPermission();
}
// Save preference to localStorage
try {
localStorage.setItem(
"sketch-notifications-enabled",
String(this.notificationsEnabled),
);
} catch (error) {
console.error("Error saving notification preference:", error);
}
}
// Handle window focus event
private _handleWindowFocus(): void {
this._windowFocused = true;
}
// Handle window blur event
private _handleWindowBlur(): void {
this._windowFocused = false;
}
// Show notification for message with EndOfTurn=true
private async showEndOfTurnNotification(
message: AgentMessage,
): Promise<void> {
// Don't show notifications if they're disabled
if (!this.notificationsEnabled) return;
// Don't show notifications if the window is focused
if (this._windowFocused) return;
// Check if we have permission to show notifications
const hasPermission = await this.checkNotificationPermission();
if (!hasPermission) return;
// Only show notifications for agent messages with end_of_turn=true
if (message.type !== "agent" || !message.end_of_turn) return;
// Create a title that includes the sketch title
const notificationTitle = `Sketch: ${this.title || "untitled"}`;
// Extract the beginning of the message content (first 100 chars)
const messagePreview = message.content
? message.content.substring(0, 100) +
(message.content.length > 100 ? "..." : "")
: "Agent has completed its turn";
// Create and show the notification
try {
new Notification(notificationTitle, {
body: messagePreview,
icon: "https://sketch.dev/favicon.ico", // Use sketch.dev favicon for notification
});
} catch (error) {
console.error("Error showing notification:", error);
}
}
private handleDataChanged(eventData: {
state: State;
newMessages: AgentMessage[];
isFirstFetch?: boolean;
}): void {
const { state, newMessages, isFirstFetch } = eventData;
// Check if this is the first data fetch or if there are new messages
if (isFirstFetch) {
this.messageStatus = "Initial messages loaded";
} else if (newMessages && newMessages.length > 0) {
this.messageStatus = "Updated just now";
} else {
this.messageStatus = "No new messages";
}
// Update state if we received it
if (state) {
// Ensure we're using the latest call status to prevent indicators from being stuck
if (
state.outstanding_llm_calls === 0 &&
state.outstanding_tool_calls.length === 0
) {
// Force reset containerState calls when nothing is reported as in progress
state.outstanding_llm_calls = 0;
state.outstanding_tool_calls = [];
}
this.containerState = state;
this.title = state.title;
// Update document title when sketch title changes
this.updateDocumentTitle();
}
// Update messages
this.messages = aggregateAgentMessages(this.messages, newMessages);
// Process new messages to find commit messages
this.updateLastCommitInfo(newMessages);
// Check for agent messages with end_of_turn=true and show notifications
if (newMessages && newMessages.length > 0 && !isFirstFetch) {
for (const message of newMessages) {
if (message.type === "agent" && message.end_of_turn) {
this.showEndOfTurnNotification(message);
break; // Only show one notification per batch of messages
}
}
}
}
private handleConnectionStatusChanged(
status: ConnectionStatus,
errorMessage?: string,
): void {
this.connectionStatus = status;
this.connectionErrorMessage = errorMessage || "";
// Update document title when connection status changes
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", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ reason: "user requested cancellation" }),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(
`Failed to stop operation: ${response.status} - ${errorData}`,
);
}
this.messageStatus = "Stop request sent";
} catch (error) {
console.error("Error stopping operation:", error);
this.messageStatus = "Failed to stop operation";
}
}
openRestartModal() {
this.restartModalOpen = true;
}
handleRestartModalClose() {
this.restartModalOpen = false;
}
async _sendChat(e: CustomEvent) {
console.log("app shell: _sendChat", e);
const message = e.detail.message?.trim();
if (message == "") {
return;
}
try {
// Send the message to the server
const response = await fetch("chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Server error: ${response.status} - ${errorData}`);
}
// TOOD(philip): If the data manager is getting messages out of order, there's a bug?
// Reset data manager state to force a full refresh after sending a message
// This ensures we get all messages in the correct order
// Use private API for now - TODO: add a resetState() method to DataManager
(this.dataManager as any).nextFetchIndex = 0;
(this.dataManager as any).currentFetchStartIndex = 0;
// // If in diff view, switch to conversation view
// if (this.viewMode === "diff") {
// await this.toggleViewMode("chat");
// }
// Refresh the timeline data to show the new message
await this.dataManager.fetchData();
} catch (error) {
console.error("Error sending chat message:", error);
const statusText = document.getElementById("statusText");
if (statusText) {
statusText.textContent = "Error sending message";
}
}
}
private scrollContainerRef = createRef<HTMLElement>();
render() {
return html`
<div id="top-banner">
<div class="title-container">
<h1 class="banner-title">sketch</h1>
<h2 id="chatTitle" class="chat-title">${this.title}</h2>
</div>
<!-- Views section with tabs -->
<sketch-view-mode-select></sketch-view-mode-select>
<!-- Container status info -->
<sketch-container-status
.state=${this.containerState}
></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>
`
: ""}
<div class="refresh-control">
<button
id="restartButton"
class="restart-button"
?disabled=${this.containerState.message_count === 0}
@click=${this.openRestartModal}
>
Restart
</button>
<button id="stopButton" class="refresh-button stop-button">
Stop
</button>
<div class="poll-updates">
<input type="checkbox" id="pollToggle" checked />
<label for="pollToggle">Poll</label>
</div>
<div
class="notifications-toggle"
@click=${this._handleNotificationsToggle}
title="${this.notificationsEnabled
? "Disable"
: "Enable"} notifications"
>
<div
class="bell-icon ${!this.notificationsEnabled
? "bell-disabled"
: ""}"
>
<!-- Bell SVG icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zM8 1.918l-.797.161A4.002 4.002 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 0 0-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5.002 5.002 0 0 1 13 6c0 .88.32 4.2 1.22 6z"
/>
</svg>
</div>
</div>
<sketch-network-status
message=${this.messageStatus}
connection=${this.connectionStatus}
error=${this.connectionErrorMessage}
></sketch-network-status>
<sketch-call-status
.llmCalls=${this.containerState?.outstanding_llm_calls || 0}
.toolCalls=${this.containerState?.outstanding_tool_calls || []}
></sketch-call-status>
</div>
</div>
<div id="view-container" ${ref(this.scrollContainerRef)}>
<div id="view-container-inner">
<div
class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}"
>
<sketch-timeline
.messages=${this.messages}
.scrollContainer=${this.scrollContainerRef}
></sketch-timeline>
</div>
<div
class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}"
>
<sketch-diff-view
.commitHash=${this.currentCommitHash}
></sketch-diff-view>
</div>
<div
class="chart-view ${this.viewMode === "charts"
? "view-active"
: ""}"
>
<sketch-charts .messages=${this.messages}></sketch-charts>
</div>
<div
class="terminal-view ${this.viewMode === "terminal"
? "view-active"
: ""}"
>
<sketch-terminal></sketch-terminal>
</div>
</div>
</div>
<div id="chat-input">
<sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
</div>
<sketch-restart-modal
?open=${this.restartModalOpen}
@close=${this.handleRestartModalClose}
.containerState=${this.containerState}
.messages=${this.messages}
></sketch-restart-modal>
`;
}
/**
* Lifecycle callback when component is first connected to DOM
*/
firstUpdated(): void {
if (this.viewMode !== "chat") {
return;
}
// Initial scroll to bottom when component is first rendered
setTimeout(
() => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
50,
);
// Setup stop button
const stopButton = this.renderRoot?.querySelector(
"#stopButton",
) as HTMLButtonElement;
stopButton?.addEventListener("click", async () => {
try {
const response = await fetch("/cancel", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ reason: "User clicked stop button" }),
});
if (!response.ok) {
console.error("Failed to cancel:", await response.text());
}
} catch (error) {
console.error("Error cancelling operation:", error);
}
});
const pollToggleCheckbox = this.renderRoot?.querySelector(
"#pollToggle",
) as HTMLInputElement;
pollToggleCheckbox?.addEventListener("change", () => {
this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
if (!pollToggleCheckbox.checked) {
this.connectionStatus = "disabled";
this.messageStatus = "Polling stopped";
} else {
this.messageStatus = "Polling for updates...";
}
});
// Process any existing messages to find commit information
if (this.messages && this.messages.length > 0) {
this.updateLastCommitInfo(this.messages);
}
}
}
declare global {
interface HTMLElementTagNameMap {
"sketch-app-shell": SketchAppShell;
}
}