import { html, nothing, type TemplateResult } from "lit" ;
import { ref } from "lit/directives/ref.js" ;
import { repeat } from "lit/directives/repeat.js" ;
import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts" ;
import {
CHAT_ATTACHMENT_ACCEPT,
isSupportedChatAttachmentMimeType,
} from "../chat/attachment-support.ts" ;
import { buildChatItems } from "../chat/build-chat-items.ts" ;
import { renderContextNotice } from "../chat/context-notice.ts" ;
import { DeletedMessages } from "../chat/deleted-messages.ts" ;
import { exportChatMarkdown } from "../chat/export.ts" ;
import {
renderMessageGroup,
renderReadingIndicatorGroup,
renderStreamingGroup,
resolveAssistantTextAvatar,
} from "../chat/grouped-render.ts" ;
import { InputHistory } from "../chat/input-history.ts" ;
import { PinnedMessages } from "../chat/pinned-messages.ts" ;
import { getPinnedMessageSummary } from "../chat/pinned-summary.ts" ;
import type { RealtimeTalkStatus } from "../chat/realtime-talk.ts" ;
import { renderChatRunControls } from "../chat/run-controls.ts" ;
import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts" ;
import { renderSideResult } from "../chat/side-result-render.ts" ;
import type { ChatSideResult } from "../chat/side-result.ts" ;
import {
CATEGORY_LABELS,
SLASH_COMMANDS,
getHiddenCommandCount,
getSlashCommandCompletions,
type SlashCommandCategory,
type SlashCommandDef,
} from "../chat/slash-commands.ts" ;
import { isSttSupported, startStt, stopStt } from "../chat/speech.ts" ;
import { renderCompactionIndicator, renderFallbackIndicator } from "../chat/status-indicators.ts" ;
import { buildSidebarContent } from "../chat/tool-cards.ts" ;
import { getExpandedToolCards, syncToolCardExpansionState } from "../chat/tool-expansion-state.ts" ;
import type { EmbedSandboxMode } from "../embed-sandbox.ts" ;
import { icons } from "../icons.ts" ;
import type { SidebarContent } from "../sidebar-content.ts" ;
import { detectTextDirection } from "../text-direction.ts" ;
import type { SessionsListResult } from "../types.ts" ;
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts" ;
import { resolveLocalUserName } from "../user-identity.ts" ;
import { agentLogoUrl, resolveChatAvatarRenderUrl } from "./agents-utils.ts" ;
import { renderMarkdownSidebar } from "./markdown-sidebar.ts" ;
import "../components/resizable-divider.ts" ;
export type ChatProps = {
sessionKey: string;
onSessionKeyChange: (next: string) => void ;
thinkingLevel: string | null ;
showThinking: boolean ;
showToolCalls: boolean ;
loading: boolean ;
sending: boolean ;
canAbort?: boolean ;
compactionStatus?: CompactionStatus | null ;
fallbackStatus?: FallbackStatus | null ;
messages: unknown[];
sideResult?: ChatSideResult | null ;
toolMessages: unknown[];
streamSegments: Array<{ text: string; ts: number }>;
stream: string | null ;
streamStartedAt: number | null ;
assistantAvatarUrl?: string | null ;
draft: string;
queue: ChatQueueItem[];
realtimeTalkActive?: boolean ;
realtimeTalkStatus?: RealtimeTalkStatus;
realtimeTalkDetail?: string | null ;
realtimeTalkTranscript?: string | null ;
connected: boolean ;
canSend: boolean ;
disabledReason: string | null ;
error: string | null ;
sessions: SessionsListResult | null ;
focusMode: boolean ;
sidebarOpen?: boolean ;
sidebarContent?: SidebarContent | null ;
sidebarError?: string | null ;
splitRatio?: number;
canvasHostUrl?: string | null ;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean ;
assistantName: string;
assistantAvatar: string | null ;
userName?: string | null ;
userAvatar?: string | null ;
localMediaPreviewRoots?: string[];
assistantAttachmentAuthToken?: string | null ;
autoExpandToolCalls?: boolean ;
attachments?: ChatAttachment[];
onAttachmentsChange?: (attachments: ChatAttachment[]) => void ;
showNewMessages?: boolean ;
onScrollToBottom?: () => void ;
onRefresh: () => void ;
onToggleFocusMode: () => void ;
getDraft?: () => string;
onDraftChange: (next: string) => void ;
onRequestUpdate?: () => void ;
onSend: () => void ;
onCompact?: () => void | Promise<void >;
onToggleRealtimeTalk?: () => void ;
onAbort?: () => void ;
onQueueRemove: (id: string) => void ;
onQueueSteer?: (id: string) => void ;
onDismissSideResult?: () => void ;
onNewSession: () => void ;
onClearHistory?: () => void ;
agentsList: {
agents: Array<{ id: string; name?: string; identity?: { name?: string; avatarUrl?: string } }>;
defaultId?: string;
} | null ;
currentAgentId: string;
onAgentChange: (agentId: string) => void ;
onNavigateToAgent?: () => void ;
onSessionSelect?: (sessionKey: string) => void ;
onOpenSidebar?: (content: SidebarContent) => void ;
onCloseSidebar?: () => void ;
onSplitRatioChange?: (ratio: number) => void ;
onChatScroll?: (event: Event) => void ;
basePath?: string;
};
// Persistent instances keyed by session
const inputHistories = new Map<string, InputHistory>();
const pinnedMessagesMap = new Map<string, PinnedMessages>();
const deletedMessagesMap = new Map<string, DeletedMessages>();
function getInputHistory(sessionKey: string): InputHistory {
return getOrCreateSessionCacheValue(inputHistories, sessionKey, () => new InputHistory());
}
function getPinnedMessages(sessionKey: string): PinnedMessages {
return getOrCreateSessionCacheValue(
pinnedMessagesMap,
sessionKey,
() => new PinnedMessages(sessionKey),
);
}
function getDeletedMessages(sessionKey: string): DeletedMessages {
return getOrCreateSessionCacheValue(
deletedMessagesMap,
sessionKey,
() => new DeletedMessages(sessionKey),
);
}
interface ChatEphemeralState {
sttRecording: boolean ;
sttInterimText: string;
slashMenuOpen: boolean ;
slashMenuItems: SlashCommandDef[];
slashMenuIndex: number;
slashMenuMode: "command" | "args" ;
slashMenuCommand: SlashCommandDef | null ;
slashMenuArgItems: string[];
slashMenuExpanded: boolean ;
searchOpen: boolean ;
searchQuery: string;
pinnedExpanded: boolean ;
}
function createChatEphemeralState(): ChatEphemeralState {
return {
sttRecording: false ,
sttInterimText: "" ,
slashMenuOpen: false ,
slashMenuItems: [],
slashMenuIndex: 0 ,
slashMenuMode: "command" ,
slashMenuCommand: null ,
slashMenuArgItems: [],
slashMenuExpanded: false ,
searchOpen: false ,
searchQuery: "" ,
pinnedExpanded: false ,
};
}
const vs = createChatEphemeralState();
/**
* Reset chat view ephemeral state when navigating away.
* Stops STT recording and clears search/slash UI that should not survive navigation.
*/
export function resetChatViewState() {
if (vs.sttRecording) {
stopStt();
}
Object.assign(vs, createChatEphemeralState());
}
export const cleanupChatModuleState = resetChatViewState;
function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = "auto" ;
el.style.height = `${Math.min(el.scrollHeight, 150 )}px`;
}
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36 ).slice(2 , 9 )}`;
}
function handlePaste(e: ClipboardEvent, props: ChatProps) {
const items = e.clipboardData?.items;
if (!items || !props.onAttachmentsChange) {
return ;
}
const imageItems: DataTransferItem[] = [];
for (let i = 0 ; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/" )) {
imageItems.push(item);
}
}
if (imageItems.length === 0 ) {
return ;
}
e.preventDefault();
for (const item of imageItems) {
const file = item.getAsFile();
if (!file) {
continue ;
}
const reader = new FileReader();
reader.addEventListener("load" , () => {
const dataUrl = reader.result as string;
const newAttachment: ChatAttachment = {
id: generateAttachmentId(),
dataUrl,
mimeType: file.type,
};
const current = props.attachments ?? [];
props.onAttachmentsChange?.([...current, newAttachment]);
});
reader.readAsDataURL(file);
}
}
function handleFileSelect(e: Event, props: ChatProps) {
const input = e.target as HTMLInputElement;
if (!input.files || !props.onAttachmentsChange) {
return ;
}
const current = props.attachments ?? [];
const additions: ChatAttachment[] = [];
let pending = 0 ;
for (const file of input.files) {
if (!isSupportedChatAttachmentMimeType(file.type)) {
continue ;
}
pending++;
const reader = new FileReader();
reader.addEventListener("load" , () => {
additions.push({
id: generateAttachmentId(),
dataUrl: reader.result as string,
mimeType: file.type,
});
pending--;
if (pending === 0 ) {
props.onAttachmentsChange?.([...current, ...additions]);
}
});
reader.readAsDataURL(file);
}
input.value = "" ;
}
function handleDrop(e: DragEvent, props: ChatProps) {
e.preventDefault();
const files = e.dataTransfer?.files;
if (!files || !props.onAttachmentsChange) {
return ;
}
const current = props.attachments ?? [];
const additions: ChatAttachment[] = [];
let pending = 0 ;
for (const file of files) {
if (!isSupportedChatAttachmentMimeType(file.type)) {
continue ;
}
pending++;
const reader = new FileReader();
reader.addEventListener("load" , () => {
additions.push({
id: generateAttachmentId(),
dataUrl: reader.result as string,
mimeType: file.type,
});
pending--;
if (pending === 0 ) {
props.onAttachmentsChange?.([...current, ...additions]);
}
});
reader.readAsDataURL(file);
}
}
function renderAttachmentPreview(props: ChatProps): TemplateResult | typeof nothing {
const attachments = props.attachments ?? [];
if (attachments.length === 0 ) {
return nothing;
}
return html`
<div class ="chat-attachments-preview" >
${attachments.map(
(att) => html`
<div class ="chat-attachment-thumb" >
<img src=${att.dataUrl} alt="Attachment preview" />
<button
class ="chat-attachment-remove"
type="button"
aria-label="Remove attachment"
@click=${() => {
const next = (props.attachments ?? []).filter((a) => a.id !== att.id);
props.onAttachmentsChange?.(next);
}}
>
×
</button>
</div>
`,
)}
</div>
`;
}
function resetSlashMenuState(): void {
vs.slashMenuMode = "command" ;
vs.slashMenuCommand = null ;
vs.slashMenuArgItems = [];
vs.slashMenuItems = [];
vs.slashMenuExpanded = false ;
}
function updateSlashMenu(value: string, requestUpdate: () => void ): void {
// Arg mode: /command <partial-arg>
const argMatch = value.match(/^\/(\S+)\s(.*)$/);
if (argMatch) {
const cmdName = argMatch[1 ].toLowerCase();
const argFilter = argMatch[2 ].toLowerCase();
const cmd = SLASH_COMMANDS.find((c) => c.name === cmdName);
if (cmd?.argOptions?.length) {
const filtered = argFilter
? cmd.argOptions.filter((opt) => opt.toLowerCase().startsWith(argFilter))
: cmd.argOptions;
if (filtered.length > 0 ) {
vs.slashMenuMode = "args" ;
vs.slashMenuCommand = cmd;
vs.slashMenuArgItems = filtered;
vs.slashMenuOpen = true ;
vs.slashMenuIndex = 0 ;
vs.slashMenuItems = [];
requestUpdate();
return ;
}
}
vs.slashMenuOpen = false ;
resetSlashMenuState();
requestUpdate();
return ;
}
// Command mode: /partial-command
const match = value.match(/^\/(\S*)$/);
if (match) {
const items = getSlashCommandCompletions(match[1 ], { showAll: vs.slashMenuExpanded });
vs.slashMenuItems = items;
vs.slashMenuOpen = items.length > 0 ;
vs.slashMenuIndex = 0 ;
vs.slashMenuMode = "command" ;
vs.slashMenuCommand = null ;
vs.slashMenuArgItems = [];
} else {
vs.slashMenuOpen = false ;
resetSlashMenuState();
}
requestUpdate();
}
function selectSlashCommand(
cmd: SlashCommandDef,
props: ChatProps,
requestUpdate: () => void ,
): void {
// Transition to arg picker when the command has fixed options
if (cmd.argOptions?.length) {
props.onDraftChange(`/${cmd.name} `);
vs.slashMenuMode = "args" ;
vs.slashMenuCommand = cmd;
vs.slashMenuArgItems = cmd.argOptions;
vs.slashMenuOpen = true ;
vs.slashMenuIndex = 0 ;
vs.slashMenuItems = [];
requestUpdate();
return ;
}
vs.slashMenuOpen = false ;
resetSlashMenuState();
if (cmd.executeLocal && !cmd.args) {
props.onDraftChange(`/${cmd.name}`);
requestUpdate();
props.onSend();
} else {
props.onDraftChange(`/${cmd.name} `);
requestUpdate();
}
}
function tabCompleteSlashCommand(
cmd: SlashCommandDef,
props: ChatProps,
requestUpdate: () => void ,
): void {
// Tab: fill in the command text without executing
if (cmd.argOptions?.length) {
props.onDraftChange(`/${cmd.name} `);
vs.slashMenuMode = "args" ;
vs.slashMenuCommand = cmd;
vs.slashMenuArgItems = cmd.argOptions;
vs.slashMenuOpen = true ;
vs.slashMenuIndex = 0 ;
vs.slashMenuItems = [];
requestUpdate();
return ;
}
vs.slashMenuOpen = false ;
resetSlashMenuState();
props.onDraftChange(cmd.args ? `/${cmd.name} ` : `/${cmd.name}`);
requestUpdate();
}
function selectSlashArg(
arg: string,
props: ChatProps,
requestUpdate: () => void ,
execute: boolean ,
): void {
const cmdName = vs.slashMenuCommand?.name ?? "" ;
vs.slashMenuOpen = false ;
resetSlashMenuState();
props.onDraftChange(`/${cmdName} ${arg}`);
requestUpdate();
if (execute) {
props.onSend();
}
}
function tokenEstimate(draft: string): string | null {
if (draft.length < 100 ) {
return null ;
}
return `~${Math.ceil(draft.length / 4 )} tokens`;
}
/**
* Export chat markdown - delegates to shared utility.
*/
function exportMarkdown(props: ChatProps): void {
exportChatMarkdown(props.messages, props.assistantName);
}
const WELCOME_SUGGESTIONS = [
"What can you do?" ,
"Summarize my recent sessions" ,
"Help me configure a channel" ,
"Check system health" ,
];
function renderWelcomeState(props: ChatProps): TemplateResult {
const name = props.assistantName || "Assistant" ;
const avatar = resolveAssistantAvatarUrl(props);
const avatarText = avatar ? null : resolveAssistantTextAvatar(props.assistantAvatar);
const logoUrl = agentLogoUrl(props.basePath ?? "" );
return html`
<div class ="agent-chat__welcome" style="--agent-color: var(--accent)" >
<div class ="agent-chat__welcome-glow" ></div>
${avatar
? html`<img
src=${avatar}
alt=${name}
style="width:56px; height:56px; border-radius:50%; object-fit:cover;"
/>`
: avatarText
? html`<div class ="agent-chat__avatar agent-chat__avatar--text" aria-label=${name}>
${avatarText}
</div>`
: html`<div class ="agent-chat__avatar agent-chat__avatar--logo" >
<img src=${logoUrl} alt="OpenClaw" />
</div>`}
<h2>${name}</h2>
<div class ="agent-chat__badges" >
<span class ="agent-chat__badge" ><img src=${logoUrl} alt="" /> Ready to chat</span>
</div>
<p class ="agent-chat__hint" >Type a message below · <kbd>/</kbd> for commands</p>
<div class ="agent-chat__suggestions" >
${WELCOME_SUGGESTIONS.map(
(text) => html`
<button
type="button"
class ="agent-chat__suggestion"
@click=${() => {
props.onDraftChange(text);
props.onSend();
}}
>
${text}
</button>
`,
)}
</div>
</div>
`;
}
function resolveAssistantAvatarUrl(
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl" >,
): string | null {
return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
},
});
}
function resolveAssistantDisplayAvatar(
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl" >,
): string | null {
return resolveAssistantAvatarUrl(props) ?? resolveAssistantTextAvatar(props.assistantAvatar);
}
function renderSearchBar(requestUpdate: () => void ): TemplateResult | typeof nothing {
if (!vs.searchOpen) {
return nothing;
}
return html`
<div class ="agent-chat__search-bar" >
${icons.search}
<input
type="text"
placeholder="Search messages..."
aria-label="Search messages"
.value=${vs.searchQuery}
@input=${(e: Event) => {
vs.searchQuery = (e.target as HTMLInputElement).value;
requestUpdate();
}}
/>
<button
class ="btn btn--ghost"
aria-label="Close search"
@click=${() => {
vs.searchOpen = false ;
vs.searchQuery = "" ;
requestUpdate();
}}
>
${icons.x}
</button>
</div>
`;
}
function renderPinnedSection(
props: ChatProps,
pinned: PinnedMessages,
requestUpdate: () => void ,
): TemplateResult | typeof nothing {
const userRoleLabel = resolveLocalUserName({
name: props.userName ?? null ,
avatar: props.userAvatar ?? null ,
});
const messages = Array.isArray(props.messages) ? props.messages : [];
const entries: Array<{ index: number; text: string; role: string }> = [];
for (const idx of pinned.indices) {
const msg = messages[idx] as Record<string, unknown> | undefined;
if (!msg) {
continue ;
}
const text = getPinnedMessageSummary(msg);
const role = typeof msg.role === "string" ? msg.role : "unknown" ;
entries.push({ index: idx, text, role });
}
if (entries.length === 0 ) {
return nothing;
}
return html`
<div class ="agent-chat__pinned" >
<button
class ="agent-chat__pinned-toggle"
aria-expanded=${vs.pinnedExpanded}
@click=${() => {
vs.pinnedExpanded = !vs.pinnedExpanded;
requestUpdate();
}}
>
${icons.bookmark} ${entries.length} pinned
<span class ="collapse-chevron ${vs.pinnedExpanded ? " " : " collapse-chevron--collapsed"}"
>${icons.chevronDown}</span
>
</button>
${vs.pinnedExpanded
? html`
<div class ="agent-chat__pinned-list" >
${entries.map(
({ index, text, role }) => html`
<div class ="agent-chat__pinned-item" >
<span class ="agent-chat__pinned-role"
>${role === "user" ? userRoleLabel : "Assistant" }</span
>
<span class ="agent-chat__pinned-text"
>${text.slice(0 , 100 )}${text.length > 100 ? "..." : "" }</span
>
<button
class ="btn btn--ghost"
@click=${() => {
pinned.unpin(index);
requestUpdate();
}}
title="Unpin"
>
${icons.x}
</button>
</div>
`,
)}
</div>
`
: nothing}
</div>
`;
}
function renderSlashMenu(
requestUpdate: () => void ,
props: ChatProps,
): TemplateResult | typeof nothing {
if (!vs.slashMenuOpen) {
return nothing;
}
// Arg-picker mode: show options for the selected command
if (vs.slashMenuMode === "args" && vs.slashMenuCommand && vs.slashMenuArgItems.length > 0 ) {
return html`
<div class ="slash-menu" role="listbox" aria-label="Command arguments" >
<div class ="slash-menu-group" >
<div class ="slash-menu-group__label" >
/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description}
</div>
${vs.slashMenuArgItems.map(
(arg, i) => html`
<div
class ="slash-menu-item ${i === vs.slashMenuIndex ? " slash-menu-item--active" : " "}"
role="option"
aria-selected=${i === vs.slashMenuIndex}
@click=${() => selectSlashArg(arg, props, requestUpdate, true )}
@mouseenter=${() => {
vs.slashMenuIndex = i;
requestUpdate();
}}
>
${vs.slashMenuCommand?.icon
? html`<span class ="slash-menu-icon" >${icons[vs.slashMenuCommand.icon]}</span>`
: nothing}
<span class ="slash-menu-name" >${arg}</span>
<span class ="slash-menu-desc" >/${vs.slashMenuCommand?.name} ${arg}</span>
</div>
`,
)}
</div>
<div class ="slash-menu-footer" >
<kbd>↑↓</kbd> navigate <kbd>Tab</kbd> fill <kbd>Enter</kbd> run <kbd>Esc</kbd> close
</div>
</div>
`;
}
// Command mode: show grouped commands
if (vs.slashMenuItems.length === 0 ) {
return nothing;
}
const grouped = new Map<
SlashCommandCategory,
Array<{ cmd: SlashCommandDef; globalIdx: number }>
>();
for (let i = 0 ; i < vs.slashMenuItems.length; i++) {
const cmd = vs.slashMenuItems[i];
const cat = cmd.category ?? "session" ;
let list = grouped.get(cat);
if (!list) {
list = [];
grouped.set(cat, list);
}
list.push({ cmd, globalIdx: i });
}
const sections: TemplateResult[] = [];
for (const [cat, entries] of grouped) {
sections.push(html`
<div class ="slash-menu-group" >
<div class ="slash-menu-group__label" >${CATEGORY_LABELS[cat]}</div>
${entries.map(
({ cmd, globalIdx }) => html`
<div
class ="slash-menu-item ${globalIdx === vs.slashMenuIndex
? "slash-menu-item--active"
: "" }"
role="option"
aria-selected=${globalIdx === vs.slashMenuIndex}
@click=${() => selectSlashCommand(cmd, props, requestUpdate)}
@mouseenter=${() => {
vs.slashMenuIndex = globalIdx;
requestUpdate();
}}
>
${cmd.icon ? html`<span class ="slash-menu-icon" >${icons[cmd.icon]}</span>` : nothing}
<span class ="slash-menu-name" >/${cmd.name}</span>
${cmd.args ? html`<span class ="slash-menu-args" >${cmd.args}</span>` : nothing}
<span class ="slash-menu-desc" >${cmd.description}</span>
${cmd.argOptions?.length
? html`<span class ="slash-menu-badge" >${cmd.argOptions.length} options</span>`
: cmd.executeLocal && !cmd.args
? html` <span class ="slash-menu-badge" >instant</span> `
: nothing}
</div>
`,
)}
</div>
`);
}
const hiddenCount = vs.slashMenuExpanded ? 0 : getHiddenCommandCount();
return html`
<div class ="slash-menu" role="listbox" aria-label="Slash commands" >
${sections}
${hiddenCount > 0
? html`<button
class ="slash-menu-show-more"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
vs.slashMenuExpanded = true ;
updateSlashMenu(props.draft, requestUpdate);
}}
>
Show ${hiddenCount} more command${hiddenCount !== 1 ? "s" : "" }
</button>`
: nothing}
<div class ="slash-menu-footer" >
<kbd>↑↓</kbd> navigate <kbd>Tab</kbd> fill <kbd>Enter</kbd> select <kbd>Esc</kbd> close
</div>
</div>
`;
}
export function renderChat(props: ChatProps) {
const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null ;
const canAbort = Boolean (props.canAbort && props.onAbort);
const compactBusy =
props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying" ;
const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey);
const reasoningLevel = activeSession?.reasoningLevel ?? "off" ;
const showReasoning = props.showThinking && reasoningLevel !== "off" ;
const assistantIdentity = {
name: props.assistantName,
avatar: resolveAssistantDisplayAvatar(props),
};
const pinned = getPinnedMessages(props.sessionKey);
const deleted = getDeletedMessages(props.sessionKey);
const inputHistory = getInputHistory(props.sessionKey);
const hasAttachments = (props.attachments?.length ?? 0 ) > 0 ;
const tokens = tokenEstimate(props.draft);
const placeholder = props.connected
? hasAttachments
? "Add a message or paste more images..."
: `Message ${props.assistantName || "agent" } (Enter to send)`
: "Connect to the gateway to start chatting..." ;
const requestUpdate = props.onRequestUpdate ?? (() => {});
const getDraft = props.getDraft ?? (() => props.draft);
const splitRatio = props.splitRatio ?? 0 .6 ;
const sidebarOpen = Boolean (props.sidebarOpen && props.onCloseSidebar);
const handleCodeBlockCopy = (e: Event) => {
const btn = (e.target as HTMLElement).closest(".code-block-copy" );
if (!btn) {
return ;
}
const code = (btn as HTMLElement).dataset.code ?? "" ;
navigator.clipboard.writeText(code).then(
() => {
btn.classList.add("copied" );
setTimeout(() => btn.classList.remove("copied" ), 1500 );
},
() => {},
);
};
const chatItems = buildChatItems({
sessionKey: props.sessionKey,
messages: props.messages,
toolMessages: props.toolMessages,
streamSegments: props.streamSegments,
stream: props.stream,
streamStartedAt: props.streamStartedAt,
showToolCalls: props.showToolCalls,
searchOpen: vs.searchOpen,
searchQuery: vs.searchQuery,
});
syncToolCardExpansionState(props.sessionKey, chatItems, Boolean (props.autoExpandToolCalls));
const expandedToolCards = getExpandedToolCards(props.sessionKey);
const toggleToolCardExpanded = (toolCardId: string) => {
expandedToolCards.set(toolCardId, !expandedToolCards.get(toolCardId));
requestUpdate();
};
const isEmpty = chatItems.length === 0 && !props.loading;
const thread = html`
<div
class ="chat-thread"
role="log"
aria-live="polite"
@scroll=${props.onChatScroll}
@click=${handleCodeBlockCopy}
>
<div class ="chat-thread-inner" >
${props.loading
? html`
<div class ="chat-loading-skeleton" aria-label="Loading chat" >
<div class ="chat-line assistant" >
<div class ="chat-msg" >
<div class ="chat-bubble" >
<div
class ="skeleton skeleton-line skeleton-line--long"
style="margin-bottom: 8px"
></div>
<div
class ="skeleton skeleton-line skeleton-line--medium"
style="margin-bottom: 8px"
></div>
<div class ="skeleton skeleton-line skeleton-line--short" ></div>
</div>
</div>
</div>
<div class ="chat-line user" style="margin-top: 12px" >
<div class ="chat-msg" >
<div class ="chat-bubble" >
<div class ="skeleton skeleton-line skeleton-line--medium" ></div>
</div>
</div>
</div>
<div class ="chat-line assistant" style="margin-top: 12px" >
<div class ="chat-msg" >
<div class ="chat-bubble" >
<div
class ="skeleton skeleton-line skeleton-line--long"
style="margin-bottom: 8px"
></div>
<div class ="skeleton skeleton-line skeleton-line--short" ></div>
</div>
</div>
</div>
</div>
`
: nothing}
${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing}
${isEmpty && vs.searchOpen
? html` <div class ="agent-chat__empty" >No matching messages</div> `
: nothing}
${repeat(
chatItems,
(item) => item.key,
(item) => {
if (item.kind === "divider" ) {
return html`
<div class ="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
<span class ="chat-divider__line" ></span>
<span class ="chat-divider__label" >${item.label}</span>
<span class ="chat-divider__line" ></span>
</div>
`;
}
if (item.kind === "reading-indicator" ) {
return renderReadingIndicatorGroup(
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null ,
);
}
if (item.kind === "stream" ) {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null ,
);
}
if (item.kind === "group" ) {
if (deleted.has(item.key)) {
return nothing;
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
showToolCalls: props.showToolCalls,
autoExpandToolCalls: Boolean (props.autoExpandToolCalls),
isToolMessageExpanded: (messageId: string) =>
expandedToolCards.get(messageId) ?? false ,
onToggleToolMessageExpanded: (messageId: string) => {
expandedToolCards.set(messageId, !expandedToolCards.get(messageId));
requestUpdate();
},
isToolExpanded: (toolCardId: string) => expandedToolCards.get(toolCardId) ?? false ,
onToggleToolExpanded: toggleToolCardExpanded,
onRequestUpdate: requestUpdate,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
userName: props.userName ?? null ,
userAvatar: props.userAvatar ?? null ,
basePath: props.basePath,
localMediaPreviewRoots: props.localMediaPreviewRoots ?? [],
assistantAttachmentAuthToken: props.assistantAttachmentAuthToken ?? null ,
canvasHostUrl: props.canvasHostUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts" ,
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false ,
contextWindow:
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null ,
onDelete: () => {
deleted.delete (item.key);
requestUpdate();
},
});
}
return nothing;
},
)}
</div>
</div>
`;
const handleKeyDown = (e: KeyboardEvent) => {
// Slash menu navigation — arg mode
if (vs.slashMenuOpen && vs.slashMenuMode === "args" && vs.slashMenuArgItems.length > 0 ) {
const len = vs.slashMenuArgItems.length;
switch (e.key) {
case "ArrowDown" :
e.preventDefault();
vs.slashMenuIndex = (vs.slashMenuIndex + 1 ) % len;
requestUpdate();
return ;
case "ArrowUp" :
e.preventDefault();
vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len;
requestUpdate();
return ;
case "Tab" :
e.preventDefault();
selectSlashArg(vs.slashMenuArgItems[vs.slashMenuIndex], props, requestUpdate, false );
return ;
case "Enter" :
e.preventDefault();
selectSlashArg(vs.slashMenuArgItems[vs.slashMenuIndex], props, requestUpdate, true );
return ;
case "Escape" :
e.preventDefault();
vs.slashMenuOpen = false ;
resetSlashMenuState();
requestUpdate();
return ;
}
}
// Slash menu navigation — command mode
if (vs.slashMenuOpen && vs.slashMenuItems.length > 0 ) {
const len = vs.slashMenuItems.length;
switch (e.key) {
case "ArrowDown" :
e.preventDefault();
vs.slashMenuIndex = (vs.slashMenuIndex + 1 ) % len;
requestUpdate();
return ;
case "ArrowUp" :
e.preventDefault();
vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len;
requestUpdate();
return ;
case "Tab" :
e.preventDefault();
tabCompleteSlashCommand(vs.slashMenuItems[vs.slashMenuIndex], props, requestUpdate);
return ;
case "Enter" :
e.preventDefault();
selectSlashCommand(vs.slashMenuItems[vs.slashMenuIndex], props, requestUpdate);
return ;
case "Escape" :
e.preventDefault();
vs.slashMenuOpen = false ;
resetSlashMenuState();
requestUpdate();
return ;
}
}
if (e.key === "Escape" && props.sideResult && !vs.searchOpen) {
e.preventDefault();
props.onDismissSideResult?.();
return ;
}
// Input history (only when input is empty)
if (!props.draft.trim()) {
if (e.key === "ArrowUp" ) {
const prev = inputHistory.up();
if (prev !== null ) {
e.preventDefault();
props.onDraftChange(prev);
}
return ;
}
if (e.key === "ArrowDown" ) {
const next = inputHistory.down();
e.preventDefault();
props.onDraftChange(next ?? "" );
return ;
}
}
// Cmd+F for search
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "f" ) {
e.preventDefault();
vs.searchOpen = !vs.searchOpen;
if (!vs.searchOpen) {
vs.searchQuery = "" ;
}
requestUpdate();
return ;
}
// Send on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
if (e.isComposing || e.keyCode === 229 ) {
return ;
}
if (!props.connected) {
return ;
}
e.preventDefault();
if (canCompose) {
if (props.draft.trim()) {
inputHistory.push(props.draft);
}
props.onSend();
}
}
};
const handleInput = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
adjustTextareaHeight(target);
updateSlashMenu(target.value, requestUpdate);
inputHistory.reset();
props.onDraftChange(target.value);
};
return html`
<section
class ="card chat"
@drop=${(e: DragEvent) => handleDrop(e, props)}
@dragover=${(e: DragEvent) => e.preventDefault()}
>
${props.disabledReason ? html`<div class ="callout" >${props.disabledReason}</div>` : nothing}
${props.error ? html`<div class ="callout danger" >${props.error}</div>` : nothing}
${props.focusMode
? html`
<button
class ="chat-focus-exit"
type="button"
@click=${props.onToggleFocusMode}
aria-label="Exit focus mode"
title="Exit focus mode"
>
${icons.x}
</button>
`
: nothing}
${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
<div class ="chat-split-container ${sidebarOpen ? " chat-split-container--open" : " "}" >
<div
class ="chat-main"
style="flex: ${sidebarOpen ? `0 0 ${splitRatio * 100}%` : " 1 1 100 %"}"
>
${thread }
</div>
${sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
></resizable-divider>
<div class ="chat-sidebar" >
${renderMarkdownSidebar({
content: props.sidebarContent ?? null ,
error: props.sidebarError ?? null ,
canvasHostUrl: props.canvasHostUrl,
embedSandboxMode: props.embedSandboxMode ?? "scripts" ,
allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false ,
onClose: props.onCloseSidebar!,
onViewRawText: () => {
if (!props.sidebarContent || !props.onOpenSidebar) {
return ;
}
if (props.sidebarContent.kind === "markdown" ) {
props.onOpenSidebar(
buildSidebarContent(`\`\`\`\n${props.sidebarContent.content}\n\`\`\``),
);
return ;
}
if (props.sidebarContent.rawText?.trim()) {
props.onOpenSidebar(
buildSidebarContent(`\`\`\`json\n${props.sidebarContent.rawText}\n\`\`\``),
);
}
},
})}
</div>
`
: nothing}
</div>
${props.queue.length
? html`
<div class ="chat-queue" role="status" aria-live="polite" >
<div class ="chat-queue__title" >Queued (${props.queue.length})</div>
<div class ="chat-queue__list" >
${props.queue.map(
(item) => html`
<div
class ="chat-queue__item ${item.kind === " steered"
? "chat-queue__item--steered"
: "" }"
>
<div class ="chat-queue__main" >
${item.kind === "steered"
? html`<span class ="chat-queue__badge" >Steered</span>`
: nothing}
<div class ="chat-queue__text" >
${item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "" )}
</div>
</div>
<div class ="chat-queue__actions" >
${props.canAbort &&
props.onQueueSteer &&
item.kind !== "steered" &&
!item.localCommandName
? html`
<button
class ="btn chat-queue__steer"
type="button"
title="Steer now"
aria-label="Steer queued message"
@click=${() => props.onQueueSteer?.(item.id)}
>
${icons.cornerDownRight}
<span>Steer</span>
</button>
`
: nothing}
<button
class ="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
</button>
</div>
</div>
`,
)}
</div>
</div>
`
: nothing}
${renderSideResult(props.sideResult, props.onDismissSideResult)}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null , {
compactBusy,
compactDisabled: !props.connected || isBusy || Boolean (props.canAbort),
onCompact: props.onCompact,
})}
${props.showNewMessages
? html`
<button class ="chat-new-messages" type="button" @click=${props.onScrollToBottom}>
${icons.arrowDown} New messages
</button>
`
: nothing}
<!-- Input bar -->
<div class ="agent-chat__input" >
${renderSlashMenu(requestUpdate, props)} ${renderAttachmentPreview(props)}
<input
type="file"
accept=${CHAT_ATTACHMENT_ACCEPT}
multiple
class ="agent-chat__file-input"
@change=${(e: Event) => handleFileSelect(e, props)}
/>
${vs.sttRecording && vs.sttInterimText
? html`<div class ="agent-chat__stt-interim" >${vs.sttInterimText}</div>`
: nothing}
${props.realtimeTalkActive || props.realtimeTalkDetail || props.realtimeTalkTranscript
? html`
<div class ="agent-chat__stt-interim agent-chat__talk-status" >
${props.realtimeTalkDetail ??
props.realtimeTalkTranscript ??
(props.realtimeTalkStatus === "thinking"
? "Asking OpenClaw..."
: props.realtimeTalkStatus === "connecting"
? "Connecting Talk..."
: "Talk live" )}
</div>
`
: nothing}
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
.value=${props.draft}
dir=${detectTextDirection(props.draft)}
?disabled=${!props.connected}
@keydown=${handleKeyDown}
@input=${handleInput}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${vs.sttRecording ? "Listening..." : placeholder}
rows="1"
></textarea>
<div class ="agent-chat__toolbar" >
<div class ="agent-chat__toolbar-left" >
<button
class ="agent-chat__input-btn"
@click=${() => {
document.querySelector<HTMLInputElement>(".agent-chat__file-input" )?.click();
}}
title="Attach file"
aria-label="Attach file"
?disabled=${!props.connected}
>
${icons.paperclip}
</button>
${isSttSupported()
? html`
<button
class ="agent-chat__input-btn ${vs.sttRecording
? "agent-chat__input-btn--recording"
: "" }"
@click=${() => {
if (vs.sttRecording) {
stopStt();
vs.sttRecording = false ;
vs.sttInterimText = "" ;
requestUpdate();
} else {
const started = startStt({
onTranscript: (text, isFinal) => {
if (isFinal) {
const current = getDraft();
const sep = current && !current.endsWith(" " ) ? " " : "" ;
props.onDraftChange(current + sep + text);
vs.sttInterimText = "" ;
} else {
vs.sttInterimText = text;
}
requestUpdate();
},
onStart: () => {
vs.sttRecording = true ;
requestUpdate();
},
onEnd: () => {
vs.sttRecording = false ;
vs.sttInterimText = "" ;
requestUpdate();
},
onError: () => {
vs.sttRecording = false ;
vs.sttInterimText = "" ;
requestUpdate();
},
});
if (started) {
vs.sttRecording = true ;
requestUpdate();
}
}
}}
title=${vs.sttRecording ? "Stop recording" : "Voice input" }
aria-label=${vs.sttRecording ? "Stop recording" : "Voice input" }
?disabled=${!props.connected}
>
${vs.sttRecording ? icons.micOff : icons.mic}
</button>
`
: nothing}
${props.onToggleRealtimeTalk
? html`
<button
class ="agent-chat__input-btn ${props.realtimeTalkActive
? "agent-chat__input-btn--talk"
: "" }"
@click=${props.onToggleRealtimeTalk}
title=${props.realtimeTalkActive ? "Stop Talk" : "Start Talk" }
aria-label=${props.realtimeTalkActive ? "Stop Talk" : "Start Talk" }
?disabled=${!props.connected}
>
${props.realtimeTalkActive ? icons.volume2 : icons.radio}
</button>
`
: nothing}
${tokens ? html`<span class ="agent-chat__token-count" >${tokens}</span>` : nothing}
</div>
${renderChatRunControls({
canAbort,
connected: props.connected,
draft: props.draft,
hasMessages: props.messages.length > 0 ,
isBusy,
sending: props.sending,
onAbort: props.onAbort,
onExport: () => exportMarkdown(props),
onNewSession: props.onNewSession,
onSend: props.onSend,
onStoreDraft: (draft) => inputHistory.push(draft),
})}
</div>
</div>
</section>
`;
}
Messung V0.5 in Prozent C=99 H=99 G=98
¤ Dauer der Verarbeitung: 0.19 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland