openclaw-vainplex/ui/src/ui/views/chat.ts
Dan Guido 48aea87028
feat: add prek pre-commit hooks and dependabot (#1720)
* feat: add prek pre-commit hooks and dependabot

Pre-commit hooks (via prek):
- Basic hygiene: trailing-whitespace, end-of-file-fixer, check-yaml, check-added-large-files, check-merge-conflict
- Security: detect-secrets, zizmor (GitHub Actions audit)
- Linting: shellcheck, actionlint, oxlint, swiftlint
- Formatting: oxfmt, swiftformat

Dependabot:
- npm and GitHub Actions ecosystems
- Grouped updates (production/development/actions)
- 7-day cooldown for supply chain protection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add prek install instruction to AGENTS.md

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:53:23 +00:00

385 lines
12 KiB
TypeScript

import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../types";
import type { ChatQueueItem } from "../ui-types";
import type { ChatItem, MessageGroup } from "../types/chat-types";
import { icons } from "../icons";
import {
normalizeMessage,
normalizeRoleForGrouping,
} from "../chat/message-normalizer";
import {
renderMessageGroup,
renderReadingIndicatorGroup,
renderStreamingGroup,
} from "../chat/grouped-render";
import { renderMarkdownSidebar } from "./markdown-sidebar";
import "../components/resizable-divider";
export type CompactionIndicatorStatus = {
active: boolean;
startedAt: number | null;
completedAt: number | null;
};
export type ChatProps = {
sessionKey: string;
onSessionKeyChange: (next: string) => void;
thinkingLevel: string | null;
showThinking: boolean;
loading: boolean;
sending: boolean;
canAbort?: boolean;
compactionStatus?: CompactionIndicatorStatus | null;
messages: unknown[];
toolMessages: unknown[];
stream: string | null;
streamStartedAt: number | null;
assistantAvatarUrl?: string | null;
draft: string;
queue: ChatQueueItem[];
connected: boolean;
canSend: boolean;
disabledReason: string | null;
error: string | null;
sessions: SessionsListResult | null;
// Focus mode
focusMode: boolean;
// Sidebar state
sidebarOpen?: boolean;
sidebarContent?: string | null;
sidebarError?: string | null;
splitRatio?: number;
assistantName: string;
assistantAvatar: string | null;
// Event handlers
onRefresh: () => void;
onToggleFocusMode: () => void;
onDraftChange: (next: string) => void;
onSend: () => void;
onAbort?: () => void;
onQueueRemove: (id: string) => void;
onNewSession: () => void;
onOpenSidebar?: (content: string) => void;
onCloseSidebar?: () => void;
onSplitRatioChange?: (ratio: number) => void;
onChatScroll?: (event: Event) => void;
};
const COMPACTION_TOAST_DURATION_MS = 5000;
function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) {
if (!status) return nothing;
// Show "compacting..." while active
if (status.active) {
return html`
<div class="callout info compaction-indicator compaction-indicator--active">
${icons.loader} Compacting context...
</div>
`;
}
// Show "compaction complete" briefly after completion
if (status.completedAt) {
const elapsed = Date.now() - status.completedAt;
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
<div class="callout success compaction-indicator compaction-indicator--complete">
${icons.check} Context compacted
</div>
`;
}
}
return nothing;
}
export function renderChat(props: ChatProps) {
const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null;
const canAbort = Boolean(props.canAbort && props.onAbort);
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: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
};
const composePlaceholder = props.connected
? "Message (↩ to send, Shift+↩ for line breaks)"
: "Connect to the gateway to start chatting…";
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
const thread = html`
<div
class="chat-thread"
role="log"
aria-live="polite"
@scroll=${props.onChatScroll}
>
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
);
}
if (item.kind === "group") {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
});
}
return nothing;
})}
</div>
`;
return html`
<section class="card chat">
${props.disabledReason
? html`<div class="callout">${props.disabledReason}</div>`
: nothing}
${props.error
? html`<div class="callout danger">${props.error}</div>`
: nothing}
${renderCompactionIndicator(props.compactionStatus)}
${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}
<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,
onClose: props.onCloseSidebar!,
onViewRawText: () => {
if (!props.sidebarContent || !props.onOpenSidebar) return;
props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\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">
<div class="chat-queue__text">${item.text}</div>
<button
class="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
</button>
</div>
`,
)}
</div>
</div>
`
: nothing}
<div class="chat-compose">
<label class="field chat-compose__field">
<span>Message</span>
<textarea
.value=${props.draft}
?disabled=${!props.connected}
@keydown=${(e: KeyboardEvent) => {
if (e.key !== "Enter") return;
if (e.isComposing || e.keyCode === 229) return;
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
if (!props.connected) return;
e.preventDefault();
if (canCompose) props.onSend();
}}
@input=${(e: Event) =>
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
placeholder=${composePlaceholder}
></textarea>
</label>
<div class="chat-compose__actions">
<button
class="btn"
?disabled=${!props.connected || (!canAbort && props.sending)}
@click=${canAbort ? props.onAbort : props.onNewSession}
>
${canAbort ? "Stop" : "New session"}
</button>
<button
class="btn primary"
?disabled=${!props.connected}
@click=${props.onSend}
>
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd">↵</kbd>
</button>
</div>
</div>
</section>
`;
}
const CHAT_HISTORY_RENDER_LIMIT = 200;
function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
const result: Array<ChatItem | MessageGroup> = [];
let currentGroup: MessageGroup | null = null;
for (const item of items) {
if (item.kind !== "message") {
if (currentGroup) {
result.push(currentGroup);
currentGroup = null;
}
result.push(item);
continue;
}
const normalized = normalizeMessage(item.message);
const role = normalizeRoleForGrouping(normalized.role);
const timestamp = normalized.timestamp || Date.now();
if (!currentGroup || currentGroup.role !== role) {
if (currentGroup) result.push(currentGroup);
currentGroup = {
kind: "group",
key: `group:${role}:${item.key}`,
role,
messages: [{ message: item.message, key: item.key }],
timestamp,
isStreaming: false,
};
} else {
currentGroup.messages.push({ message: item.message, key: item.key });
}
}
if (currentGroup) result.push(currentGroup);
return result;
}
function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
const items: ChatItem[] = [];
const history = Array.isArray(props.messages) ? props.messages : [];
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT);
if (historyStart > 0) {
items.push({
kind: "message",
key: "chat:history:notice",
message: {
role: "system",
content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`,
timestamp: Date.now(),
},
});
}
for (let i = historyStart; i < history.length; i++) {
const msg = history[i];
const normalized = normalizeMessage(msg);
if (!props.showThinking && normalized.role.toLowerCase() === "toolresult") {
continue;
}
items.push({
kind: "message",
key: messageKey(msg, i),
message: msg,
});
}
if (props.showThinking) {
for (let i = 0; i < tools.length; i++) {
items.push({
kind: "message",
key: messageKey(tools[i], i + history.length),
message: tools[i],
});
}
}
if (props.stream !== null) {
const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`;
if (props.stream.trim().length > 0) {
items.push({
kind: "stream",
key,
text: props.stream,
startedAt: props.streamStartedAt ?? Date.now(),
});
} else {
items.push({ kind: "reading-indicator", key });
}
}
return groupMessages(items);
}
function messageKey(message: unknown, index: number): string {
const m = message as Record<string, unknown>;
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
if (toolCallId) return `tool:${toolCallId}`;
const id = typeof m.id === "string" ? m.id : "";
if (id) return `msg:${id}`;
const messageId = typeof m.messageId === "string" ? m.messageId : "";
if (messageId) return `msg:${messageId}`;
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
const role = typeof m.role === "string" ? m.role : "unknown";
if (timestamp != null) return `msg:${role}:${timestamp}:${index}`;
return `msg:${role}:${index}`;
}