feat(webchat): add image paste support
- Add paste event handler to chat textarea to capture clipboard images - Add image preview UI with thumbnails and remove buttons - Update sendChatMessage to pass attachments to chat.send RPC - Add CSS styles for attachment preview (light/dark theme support) Closes #1681 (image paste support portion) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
08183fe009
commit
fabdf2f6f7
@ -103,7 +103,7 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: auto; /* Push to bottom of flex container */
|
margin-top: auto; /* Push to bottom of flex container */
|
||||||
padding: 12px 4px 4px;
|
padding: 12px 4px 4px;
|
||||||
@ -111,6 +111,92 @@
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image attachments preview */
|
||||||
|
.chat-attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment__remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment:hover .chat-attachment__remove {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment__remove:hover {
|
||||||
|
background: rgba(220, 38, 38, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment__remove svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme attachment overrides */
|
||||||
|
:root[data-theme="light"] .chat-attachments {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: rgba(16, 24, 40, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .chat-attachment {
|
||||||
|
border-color: rgba(16, 24, 40, 0.15);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .chat-attachment__remove {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compose input row - horizontal layout */
|
||||||
|
.chat-compose__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-compose {
|
:root[data-theme="light"] .chat-compose {
|
||||||
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
|
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat";
|
import { abortChatRun, loadChatHistory, sendChatMessage, type ChatAttachment } from "./controllers/chat";
|
||||||
import { loadSessions } from "./controllers/sessions";
|
import { loadSessions } from "./controllers/sessions";
|
||||||
import { generateUUID } from "./uuid";
|
import { generateUUID } from "./uuid";
|
||||||
import { resetToolStream } from "./app-tool-stream";
|
import { resetToolStream } from "./app-tool-stream";
|
||||||
@ -12,6 +12,7 @@ import type { ClawdbotApp } from "./app";
|
|||||||
type ChatHost = {
|
type ChatHost = {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
chatMessage: string;
|
chatMessage: string;
|
||||||
|
chatAttachments: ChatAttachment[];
|
||||||
chatQueue: Array<{ id: string; text: string; createdAt: number }>;
|
chatQueue: Array<{ id: string; text: string; createdAt: number }>;
|
||||||
chatRunId: string | null;
|
chatRunId: string | null;
|
||||||
chatSending: boolean;
|
chatSending: boolean;
|
||||||
@ -61,10 +62,10 @@ function enqueueChatMessage(host: ChatHost, text: string) {
|
|||||||
async function sendChatMessageNow(
|
async function sendChatMessageNow(
|
||||||
host: ChatHost,
|
host: ChatHost,
|
||||||
message: string,
|
message: string,
|
||||||
opts?: { previousDraft?: string; restoreDraft?: boolean },
|
opts?: { previousDraft?: string; restoreDraft?: boolean; attachments?: ChatAttachment[] },
|
||||||
) {
|
) {
|
||||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||||
const ok = await sendChatMessage(host as unknown as ClawdbotApp, message);
|
const ok = await sendChatMessage(host as unknown as ClawdbotApp, message, opts?.attachments);
|
||||||
if (!ok && opts?.previousDraft != null) {
|
if (!ok && opts?.previousDraft != null) {
|
||||||
host.chatMessage = opts.previousDraft;
|
host.chatMessage = opts.previousDraft;
|
||||||
}
|
}
|
||||||
@ -104,7 +105,11 @@ export async function handleSendChat(
|
|||||||
if (!host.connected) return;
|
if (!host.connected) return;
|
||||||
const previousDraft = host.chatMessage;
|
const previousDraft = host.chatMessage;
|
||||||
const message = (messageOverride ?? host.chatMessage).trim();
|
const message = (messageOverride ?? host.chatMessage).trim();
|
||||||
if (!message) return;
|
const attachments = host.chatAttachments ?? [];
|
||||||
|
const hasAttachments = attachments.length > 0;
|
||||||
|
|
||||||
|
// Allow sending with just attachments (no message text required)
|
||||||
|
if (!message && !hasAttachments) return;
|
||||||
|
|
||||||
if (isChatStopCommand(message)) {
|
if (isChatStopCommand(message)) {
|
||||||
await handleAbortChat(host);
|
await handleAbortChat(host);
|
||||||
@ -113,6 +118,8 @@ export async function handleSendChat(
|
|||||||
|
|
||||||
if (messageOverride == null) {
|
if (messageOverride == null) {
|
||||||
host.chatMessage = "";
|
host.chatMessage = "";
|
||||||
|
// Clear attachments when sending
|
||||||
|
host.chatAttachments = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isChatBusy(host)) {
|
if (isChatBusy(host)) {
|
||||||
@ -123,6 +130,7 @@ export async function handleSendChat(
|
|||||||
await sendChatMessageNow(host, message, {
|
await sendChatMessageNow(host, message, {
|
||||||
previousDraft: messageOverride == null ? previousDraft : undefined,
|
previousDraft: messageOverride == null ? previousDraft : undefined,
|
||||||
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
|
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
|
||||||
|
attachments: hasAttachments ? attachments : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -477,6 +477,8 @@ export function renderApp(state: AppViewState) {
|
|||||||
},
|
},
|
||||||
onChatScroll: (event) => state.handleChatScroll(event),
|
onChatScroll: (event) => state.handleChatScroll(event),
|
||||||
onDraftChange: (next) => (state.chatMessage = next),
|
onDraftChange: (next) => (state.chatMessage = next),
|
||||||
|
attachments: state.chatAttachments,
|
||||||
|
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||||
onSend: () => state.handleSendChat(),
|
onSend: () => state.handleSendChat(),
|
||||||
canAbort: Boolean(state.chatRunId),
|
canAbort: Boolean(state.chatRunId),
|
||||||
onAbort: () => void state.handleAbortChat(),
|
onAbort: () => void state.handleAbortChat(),
|
||||||
|
|||||||
@ -129,6 +129,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() chatAvatarUrl: string | null = null;
|
@state() chatAvatarUrl: string | null = null;
|
||||||
@state() chatThinkingLevel: string | null = null;
|
@state() chatThinkingLevel: string | null = null;
|
||||||
@state() chatQueue: ChatQueueItem[] = [];
|
@state() chatQueue: ChatQueueItem[] = [];
|
||||||
|
@state() chatAttachments: Array<{ id: string; dataUrl: string; mimeType: string }> = [];
|
||||||
// Sidebar state for tool output viewing
|
// Sidebar state for tool output viewing
|
||||||
@state() sidebarOpen = false;
|
@state() sidebarOpen = false;
|
||||||
@state() sidebarContent: string | null = null;
|
@state() sidebarContent: string | null = null;
|
||||||
|
|||||||
@ -2,6 +2,12 @@ import { extractText } from "../chat/message-extract";
|
|||||||
import type { GatewayBrowserClient } from "../gateway";
|
import type { GatewayBrowserClient } from "../gateway";
|
||||||
import { generateUUID } from "../uuid";
|
import { generateUUID } from "../uuid";
|
||||||
|
|
||||||
|
export type ChatAttachment = {
|
||||||
|
id: string;
|
||||||
|
dataUrl: string;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChatState = {
|
export type ChatState = {
|
||||||
client: GatewayBrowserClient | null;
|
client: GatewayBrowserClient | null;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@ -11,6 +17,7 @@ export type ChatState = {
|
|||||||
chatThinkingLevel: string | null;
|
chatThinkingLevel: string | null;
|
||||||
chatSending: boolean;
|
chatSending: boolean;
|
||||||
chatMessage: string;
|
chatMessage: string;
|
||||||
|
chatAttachments: ChatAttachment[];
|
||||||
chatRunId: string | null;
|
chatRunId: string | null;
|
||||||
chatStream: string | null;
|
chatStream: string | null;
|
||||||
chatStreamStartedAt: number | null;
|
chatStreamStartedAt: number | null;
|
||||||
@ -43,17 +50,44 @@ export async function loadChatHistory(state: ChatState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendChatMessage(state: ChatState, message: string): Promise<boolean> {
|
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
|
||||||
|
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
||||||
|
if (!match) return null;
|
||||||
|
return { mimeType: match[1], content: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendChatMessage(
|
||||||
|
state: ChatState,
|
||||||
|
message: string,
|
||||||
|
attachments?: ChatAttachment[],
|
||||||
|
): Promise<boolean> {
|
||||||
if (!state.client || !state.connected) return false;
|
if (!state.client || !state.connected) return false;
|
||||||
const msg = message.trim();
|
const msg = message.trim();
|
||||||
if (!msg) return false;
|
const hasAttachments = attachments && attachments.length > 0;
|
||||||
|
if (!msg && !hasAttachments) return false;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Build user message content blocks
|
||||||
|
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
|
||||||
|
if (msg) {
|
||||||
|
contentBlocks.push({ type: "text", text: msg });
|
||||||
|
}
|
||||||
|
// Add image previews to the message for display
|
||||||
|
if (hasAttachments) {
|
||||||
|
for (const att of attachments) {
|
||||||
|
contentBlocks.push({
|
||||||
|
type: "image",
|
||||||
|
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.chatMessages = [
|
state.chatMessages = [
|
||||||
...state.chatMessages,
|
...state.chatMessages,
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: msg }],
|
content: contentBlocks,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -64,12 +98,29 @@ export async function sendChatMessage(state: ChatState, message: string): Promis
|
|||||||
state.chatRunId = runId;
|
state.chatRunId = runId;
|
||||||
state.chatStream = "";
|
state.chatStream = "";
|
||||||
state.chatStreamStartedAt = now;
|
state.chatStreamStartedAt = now;
|
||||||
|
|
||||||
|
// Convert attachments to API format
|
||||||
|
const apiAttachments = hasAttachments
|
||||||
|
? attachments
|
||||||
|
.map((att) => {
|
||||||
|
const parsed = dataUrlToBase64(att.dataUrl);
|
||||||
|
if (!parsed) return null;
|
||||||
|
return {
|
||||||
|
type: "image",
|
||||||
|
mimeType: parsed.mimeType,
|
||||||
|
content: parsed.content,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((a): a is NonNullable<typeof a> => a !== null)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await state.client.request("chat.send", {
|
await state.client.request("chat.send", {
|
||||||
sessionKey: state.sessionKey,
|
sessionKey: state.sessionKey,
|
||||||
message: msg,
|
message: msg,
|
||||||
deliver: false,
|
deliver: false,
|
||||||
idempotencyKey: runId,
|
idempotencyKey: runId,
|
||||||
|
attachments: apiAttachments,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -22,6 +22,12 @@ export type CompactionIndicatorStatus = {
|
|||||||
completedAt: number | null;
|
completedAt: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatAttachment = {
|
||||||
|
id: string;
|
||||||
|
dataUrl: string;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChatProps = {
|
export type ChatProps = {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
onSessionKeyChange: (next: string) => void;
|
onSessionKeyChange: (next: string) => void;
|
||||||
@ -52,6 +58,9 @@ export type ChatProps = {
|
|||||||
splitRatio?: number;
|
splitRatio?: number;
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
assistantAvatar: string | null;
|
assistantAvatar: string | null;
|
||||||
|
// Image attachments
|
||||||
|
attachments?: ChatAttachment[];
|
||||||
|
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||||
// Event handlers
|
// Event handlers
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onToggleFocusMode: () => void;
|
onToggleFocusMode: () => void;
|
||||||
@ -95,6 +104,82 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
|
|||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.onload = () => {
|
||||||
|
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 renderAttachmentPreview(props: ChatProps) {
|
||||||
|
const attachments = props.attachments ?? [];
|
||||||
|
if (attachments.length === 0) return nothing;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="chat-attachments">
|
||||||
|
${attachments.map(
|
||||||
|
(att) => html`
|
||||||
|
<div class="chat-attachment">
|
||||||
|
<img
|
||||||
|
src=${att.dataUrl}
|
||||||
|
alt="Attachment preview"
|
||||||
|
class="chat-attachment__img"
|
||||||
|
/>
|
||||||
|
<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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${icons.x}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderChat(props: ChatProps) {
|
export function renderChat(props: ChatProps) {
|
||||||
const canCompose = props.connected;
|
const canCompose = props.connected;
|
||||||
const isBusy = props.sending || props.stream !== null;
|
const isBusy = props.sending || props.stream !== null;
|
||||||
@ -109,8 +194,11 @@ export function renderChat(props: ChatProps) {
|
|||||||
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
|
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasAttachments = (props.attachments?.length ?? 0) > 0;
|
||||||
const composePlaceholder = props.connected
|
const composePlaceholder = props.connected
|
||||||
? "Message (↩ to send, Shift+↩ for line breaks)"
|
? hasAttachments
|
||||||
|
? "Add a message or paste more images..."
|
||||||
|
: "Message (↩ to send, Shift+↩ for line breaks, paste images)"
|
||||||
: "Connect to the gateway to start chatting…";
|
: "Connect to the gateway to start chatting…";
|
||||||
|
|
||||||
const splitRatio = props.splitRatio ?? 0.6;
|
const splitRatio = props.splitRatio ?? 0.6;
|
||||||
@ -235,39 +323,43 @@ export function renderChat(props: ChatProps) {
|
|||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
<div class="chat-compose">
|
<div class="chat-compose">
|
||||||
<label class="field chat-compose__field">
|
${renderAttachmentPreview(props)}
|
||||||
<span>Message</span>
|
<div class="chat-compose__row">
|
||||||
<textarea
|
<label class="field chat-compose__field">
|
||||||
.value=${props.draft}
|
<span>Message</span>
|
||||||
?disabled=${!props.connected}
|
<textarea
|
||||||
@keydown=${(e: KeyboardEvent) => {
|
.value=${props.draft}
|
||||||
if (e.key !== "Enter") return;
|
?disabled=${!props.connected}
|
||||||
if (e.isComposing || e.keyCode === 229) return;
|
@keydown=${(e: KeyboardEvent) => {
|
||||||
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
|
if (e.key !== "Enter") return;
|
||||||
if (!props.connected) return;
|
if (e.isComposing || e.keyCode === 229) return;
|
||||||
e.preventDefault();
|
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
|
||||||
if (canCompose) props.onSend();
|
if (!props.connected) return;
|
||||||
}}
|
e.preventDefault();
|
||||||
@input=${(e: Event) =>
|
if (canCompose) props.onSend();
|
||||||
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
|
}}
|
||||||
placeholder=${composePlaceholder}
|
@input=${(e: Event) =>
|
||||||
></textarea>
|
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
|
||||||
</label>
|
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
|
||||||
<div class="chat-compose__actions">
|
placeholder=${composePlaceholder}
|
||||||
<button
|
></textarea>
|
||||||
class="btn"
|
</label>
|
||||||
?disabled=${!props.connected || (!canAbort && props.sending)}
|
<div class="chat-compose__actions">
|
||||||
@click=${canAbort ? props.onAbort : props.onNewSession}
|
<button
|
||||||
>
|
class="btn"
|
||||||
${canAbort ? "Stop" : "New session"}
|
?disabled=${!props.connected || (!canAbort && props.sending)}
|
||||||
</button>
|
@click=${canAbort ? props.onAbort : props.onNewSession}
|
||||||
<button
|
>
|
||||||
class="btn primary"
|
${canAbort ? "Stop" : "New session"}
|
||||||
?disabled=${!props.connected}
|
</button>
|
||||||
@click=${props.onSend}
|
<button
|
||||||
>
|
class="btn primary"
|
||||||
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd">↵</kbd>
|
?disabled=${!props.connected}
|
||||||
</button>
|
@click=${props.onSend}
|
||||||
|
>
|
||||||
|
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd">↵</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user