feat(webchat): change Enter key to send message

Changes webchat textarea behavior:
- Enter key now sends message (was Cmd/Ctrl+Enter)
- Shift+Enter allows line breaks
- Updated placeholder text to reflect new behavior

Fixes #411

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yurii Chukhlib
2026-01-08 22:16:23 +01:00
committed by Peter Steinberger
parent ab1896dc13
commit e84cafec8a

View File

@@ -1,10 +1,9 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js"; import { repeat } from "lit/directives/repeat.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { SessionsListResult } from "../types";
import { toSanitizedMarkdownHtml } from "../markdown"; import { toSanitizedMarkdownHtml } from "../markdown";
import { resolveToolDisplay, formatToolDetail } from "../tool-display"; import { formatToolDetail, resolveToolDisplay } from "../tool-display";
import type { SessionsListResult } from "../types";
export type ChatProps = { export type ChatProps = {
sessionKey: string; sessionKey: string;
@@ -34,7 +33,7 @@ export function renderChat(props: ChatProps) {
const canCompose = props.connected && !props.sending; const canCompose = props.connected && !props.sending;
const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions); const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions);
const composePlaceholder = props.connected const composePlaceholder = props.connected
? "Message (⌘↩ to send)" ? "Message (Shift+↩ for line breaks)"
: "Connect to the gateway to start chatting…"; : "Connect to the gateway to start chatting…";
return html` return html`
@@ -53,7 +52,7 @@ export function renderChat(props: ChatProps) {
(entry) => (entry) =>
html`<option value=${entry.key}> html`<option value=${entry.key}>
${entry.displayName ?? entry.key} ${entry.displayName ?? entry.key}
</option>`, </option>`
)} )}
</select> </select>
</label> </label>
@@ -70,33 +69,41 @@ export function renderChat(props: ChatProps) {
</div> </div>
</div> </div>
${props.disabledReason ${
? html`<div class="callout" style="margin-top: 12px;"> props.disabledReason
? html`<div class="callout" style="margin-top: 12px;">
${props.disabledReason} ${props.disabledReason}
</div>` </div>`
: nothing} : nothing
}
${props.error ${
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>` props.error
: nothing} ? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing
}
<div class="chat-thread" role="log" aria-live="polite"> <div class="chat-thread" role="log" aria-live="polite">
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing} ${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => { ${repeat(
if (item.kind === "reading-indicator") return renderReadingIndicator(); buildChatItems(props),
if (item.kind === "stream") { (item) => item.key,
return renderMessage( (item) => {
{ if (item.kind === "reading-indicator") return renderReadingIndicator();
role: "assistant", if (item.kind === "stream") {
content: [{ type: "text", text: item.text }], return renderMessage(
timestamp: item.startedAt, {
}, role: "assistant",
props, content: [{ type: "text", text: item.text }],
{ streaming: true }, timestamp: item.startedAt,
); },
props,
{ streaming: true }
);
}
return renderMessage(item.message, props);
} }
return renderMessage(item.message, props); )}
})}
</div> </div>
<div class="chat-compose"> <div class="chat-compose">
@@ -107,12 +114,11 @@ export function renderChat(props: ChatProps) {
?disabled=${!props.connected} ?disabled=${!props.connected}
@keydown=${(e: KeyboardEvent) => { @keydown=${(e: KeyboardEvent) => {
if (e.key !== "Enter") return; if (e.key !== "Enter") return;
if (!e.metaKey && !e.ctrlKey) return; if (e.shiftKey) return; // Allow Shift+Enter for line breaks
e.preventDefault(); e.preventDefault();
if (canCompose) props.onSend(); if (canCompose) props.onSend();
}} }}
@input=${(e: Event) => @input=${(e: Event) => props.onDraftChange((e.target as HTMLTextAreaElement).value)}
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
placeholder=${composePlaceholder} placeholder=${composePlaceholder}
></textarea> ></textarea>
</label> </label>
@@ -231,16 +237,11 @@ type SessionOption = {
displayName?: string; displayName?: string;
}; };
function resolveSessionOptions( function resolveSessionOptions(currentKey: string, sessions: SessionsListResult | null) {
currentKey: string,
sessions: SessionsListResult | null,
) {
const now = Date.now(); const now = Date.now();
const cutoff = now - 24 * 60 * 60 * 1000; const cutoff = now - 24 * 60 * 60 * 1000;
const entries = Array.isArray(sessions?.sessions) ? sessions?.sessions ?? [] : []; const entries = Array.isArray(sessions?.sessions) ? (sessions?.sessions ?? []) : [];
const sorted = [...entries].sort( const sorted = [...entries].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
(a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0),
);
const recent: SessionOption[] = []; const recent: SessionOption[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const entry of sorted) { for (const entry of sorted) {
@@ -292,7 +293,7 @@ function renderReadingIndicator() {
function renderMessage( function renderMessage(
message: unknown, message: unknown,
props?: Pick<ChatProps, "isToolOutputExpanded" | "onToolOutputToggle">, props?: Pick<ChatProps, "isToolOutputExpanded" | "onToolOutputToggle">,
opts?: { streaming?: boolean }, opts?: { streaming?: boolean }
) { ) {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown"; const role = typeof m.role === "string" ? m.role : "unknown";
@@ -314,7 +315,7 @@ function renderMessage(
const markdown = const markdown =
display?.kind === "json" display?.kind === "json"
? ["```json", display.value, "```"].join("\n") ? ["```json", display.value, "```"].join("\n")
: display?.value ?? null; : (display?.value ?? null);
const timestamp = const timestamp =
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : ""; typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
@@ -330,9 +331,11 @@ function renderMessage(
<div class="chat-line ${klass}"> <div class="chat-line ${klass}">
<div class="chat-msg"> <div class="chat-msg">
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}"> <div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
${markdown ${
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>` markdown
: nothing} ? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${toolCards.map((card, index) => ${toolCards.map((card, index) =>
renderToolCard(card, { renderToolCard(card, {
id: `${toolCardBase}:${index}`, id: `${toolCardBase}:${index}`,
@@ -340,7 +343,7 @@ function renderMessage(
? props.isToolOutputExpanded(`${toolCardBase}:${index}`) ? props.isToolOutputExpanded(`${toolCardBase}:${index}`)
: false, : false,
onToggle: props?.onToolOutputToggle, onToggle: props?.onToolOutputToggle,
}), })
)} )}
</div> </div>
<div class="chat-stamp mono"> <div class="chat-stamp mono">
@@ -421,7 +424,7 @@ function renderToolCard(
id: string; id: string;
expanded: boolean; expanded: boolean;
onToggle?: (id: string, expanded: boolean) => void; onToggle?: (id: string, expanded: boolean) => void;
}, }
) { ) {
const display = resolveToolDisplay({ name: card.name, args: card.args }); const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display); const detail = formatToolDetail(display);
@@ -431,11 +434,10 @@ function renderToolCard(
return html` return html`
<div class="chat-tool-card"> <div class="chat-tool-card">
<div class="chat-tool-card__title">${display.emoji} ${display.label}</div> <div class="chat-tool-card__title">${display.emoji} ${display.label}</div>
${detail ${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
? html`<div class="chat-tool-card__detail">${detail}</div>` ${
: nothing} hasOutput
${hasOutput ? html`
? html`
<details <details
class="chat-tool-card__details" class="chat-tool-card__details"
?open=${expanded} ?open=${expanded}
@@ -451,14 +453,17 @@ function renderToolCard(
(${card.text?.length ?? 0} chars) (${card.text?.length ?? 0} chars)
</span> </span>
</summary> </summary>
${expanded ${
? html`<div class="chat-tool-card__output chat-text"> expanded
? html`<div class="chat-tool-card__output chat-text">
${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))} ${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))}
</div>` </div>`
: nothing} : nothing
}
</details> </details>
` `
: nothing} : nothing
}
</div> </div>
`; `;
} }