Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,47 @@ Pass `messageSuggestions` to offer optional prompt starters on fresh conversatio
/>
```

## Long-Running Turns

Backends that support long-running chat turns can opt into stop and queue UI without changing the default behavior. When no capability is provided, the input is disabled while a response is loading, matching earlier releases.

```tsx
<Chat
basePath="/chat"
fetchFn={fetchChatJson}
initialSessionId="session-123"
runCapabilities={{ cancel: true, queue: true }}
activeRunId={activeRunId}
onCancelRun={async ({ runId, sessionId }) => {
await cancelRun({ runId, sessionId });
}}
onQueueMessage={async ({ sessionId, runId, content, files }) => {
return queueMessage({ sessionId, runId, content, files });
}}
/>
```

Capability behavior:

- No support: input and message suggestions stay disabled while loading.
- Cancel support: a stop control appears while loading once both `activeRunId` and `sessionId` are available.
- Queue support: input and message suggestions stay usable while loading and submitted messages render optimistically with a queued state.
- Cancel + queue support: both controls are enabled together.

The public TypeScript API uses camelCase (`runId`, `queuedMessageId`) while adapters can map whatever wire format their backend uses. If a backend returns run IDs in response metadata, adapters can expose them generically through `getRunId`:

```tsx
<Chat
basePath="/chat"
fetchFn={fetchChatJson}
runCapabilities={{ cancel: true }}
getRunId={(metadata) => typeof metadata.run_id === 'string' ? metadata.run_id : null}
onCancelRun={cancelRun}
/>
```

`onQueueMessage` may return `{ queuedMessageId, position, runId, sessionId, status }` when the adapter has an acknowledgement payload. The UI does not require those fields, but the types preserve them for adapters that want to coordinate follow-up polling or session refreshes.

## Consumers

- **extrachill-studio** — Studio Chat tab (agent_id=5)
Expand Down
45 changes: 41 additions & 4 deletions css/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
--ec-chat-send-bg: #2563eb;
--ec-chat-send-text: #ffffff;
--ec-chat-send-disabled-opacity: 0.4;
--ec-chat-cancel-bg: #ef4444;
--ec-chat-cancel-text: #ffffff;

/* Tool messages */
--ec-chat-tool-bg: #f9fafb;
Expand Down Expand Up @@ -112,6 +114,8 @@

--ec-chat-send-bg: #3b82f6;
--ec-chat-send-text: #ffffff;
--ec-chat-cancel-bg: #f87171;
--ec-chat-cancel-text: #111827;

--ec-chat-tool-bg: #1e293b;
--ec-chat-tool-border: #334155;
Expand Down Expand Up @@ -521,6 +525,25 @@
opacity: 1;
}

.ec-chat-message--queued .ec-chat-message__bubble,
.ec-chat-message--failed .ec-chat-message__bubble {
opacity: 0.72;
}

.ec-chat-message__status {
font-size: 11px;
color: var(--ec-chat-text-muted);
margin-top: 2px;
}

.ec-chat-message--user .ec-chat-message__status {
align-self: flex-end;
}

.ec-chat-message--failed .ec-chat-message__status {
color: var(--ec-chat-tool-error);
}

/* ============================================
Chat Input
============================================ */
Expand Down Expand Up @@ -557,7 +580,8 @@
color: var(--ec-chat-text-muted);
}

.ec-chat-input__send {
.ec-chat-input__send,
.ec-chat-input__cancel {
display: flex;
align-items: center;
justify-content: center;
Expand All @@ -572,16 +596,29 @@
transition: opacity 0.15s;
}

.ec-chat-input__send:disabled {
.ec-chat-input__send {
background: var(--ec-chat-send-bg);
color: var(--ec-chat-send-text);
}

.ec-chat-input__cancel {
background: var(--ec-chat-cancel-bg);
color: var(--ec-chat-cancel-text);
}

.ec-chat-input__send:disabled,
.ec-chat-input__cancel:disabled {
opacity: var(--ec-chat-send-disabled-opacity);
cursor: not-allowed;
}

.ec-chat-input__send:not(:disabled):hover {
.ec-chat-input__send:not(:disabled):hover,
.ec-chat-input__cancel:not(:disabled):hover {
opacity: 0.9;
}

.ec-chat-input__send-icon {
.ec-chat-input__send-icon,
.ec-chat-input__stop-icon {
display: block;
}

Expand Down
31 changes: 29 additions & 2 deletions src/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ export interface ChatProps {
* ```
*/
mediaUploadFn?: MediaUploadFn;
/** Optional long-running turn capabilities supplied by the backend adapter. */
runCapabilities?: UseChatOptions['runCapabilities'];
/** Active backend run ID, when the adapter already knows it. */
activeRunId?: UseChatOptions['activeRunId'];
/** Extract a backend run ID from response metadata. */
getRunId?: UseChatOptions['getRunId'];
/** Cancel the active backend run. Called only when cancel support is enabled. */
onCancelRun?: UseChatOptions['onCancelRun'];
/** Queue a follow-up message. Called only when queue support is enabled. */
onQueueMessage?: UseChatOptions['onQueueMessage'];
/** Accessible label for the stop/cancel control. */
cancelLabel?: string;
/**
* Arbitrary metadata forwarded to the backend with each message.
* Use for client-side context injection (e.g. `{ client_context: { tab: 'compose', postId: 123 } }`).
Expand Down Expand Up @@ -220,6 +232,12 @@ export function Chat({
allowAttachments,
acceptFileTypes,
mediaUploadFn,
runCapabilities,
activeRunId,
getRunId,
onCancelRun,
onQueueMessage,
cancelLabel,
metadata,
showCopyTranscript = false,
copyTranscriptLabel,
Expand All @@ -245,6 +263,11 @@ export function Chat({
sessionContext,
metadata,
mediaUploadFn,
runCapabilities,
activeRunId,
getRunId,
onCancelRun,
onQueueMessage,
});

// Fire onUnreadChange whenever totalUnreadCount changes.
Expand Down Expand Up @@ -288,7 +311,7 @@ export function Chat({
suggestions={resolvedMessageSuggestions}
onSelect={(suggestion) => chat.sendMessage(suggestion.message ?? suggestion.label)}
label={messageSuggestionsLabel}
disabled={chat.isLoading}
disabled={chat.isLoading && !chat.canQueueMessage}
/>
</div>
) : emptyState;
Expand Down Expand Up @@ -343,7 +366,11 @@ export function Chat({

<ChatInput
onSend={chat.sendMessage}
disabled={chat.isLoading}
onCancel={chat.cancelRun}
disabled={chat.isLoading && !chat.canQueueMessage}
showCancel={chat.canCancelRun}
cancelLoading={chat.isCancelling}
cancelLabel={cancelLabel}
placeholder={placeholder}
allowAttachments={resolvedAllowAttachments}
accept={acceptFileTypes}
Expand Down
6 changes: 6 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface ChatApiConfig {

export interface SendResult {
sessionId: string;
/** Opaque ID for the accepted chat run, when supplied by the backend. */
runId: string | null;
messages: ChatMessage[];
metadata: Record<string, unknown>;
completed: boolean;
Expand All @@ -60,6 +62,8 @@ export interface SendResult {
}

export interface ContinueResult {
/** Opaque ID for the active chat run, when supplied by the backend. */
runId: string | null;
messages: ChatMessage[];
metadata: Record<string, unknown>;
completed: boolean;
Expand Down Expand Up @@ -140,6 +144,7 @@ export async function sendMessage(

return {
sessionId: raw.data.session_id,
runId: typeof raw.data.run_id === 'string' ? raw.data.run_id : null,
messages: normalizeConversation(raw.data.conversation),
metadata: raw.data.metadata ?? {},
completed: raw.data.completed,
Expand All @@ -166,6 +171,7 @@ export async function continueResponse(
}

return {
runId: typeof raw.data.run_id === 'string' ? raw.data.run_id : null,
messages: raw.data.new_messages.map(normalizeMessage),
metadata: raw.data.metadata ?? {},
completed: raw.data.completed,
Expand Down
32 changes: 32 additions & 0 deletions src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@ import { useState, useRef, useCallback, type KeyboardEvent, type FormEvent, type
export interface ChatInputProps {
/** Called when the user submits a message (with optional file attachments). */
onSend: (content: string, files?: File[]) => void;
/** Called when the user requests cancellation of the active run. */
onCancel?: () => void;
/** Whether input is disabled (e.g. while waiting for response). */
disabled?: boolean;
/** Whether to show the stop/cancel control. */
showCancel?: boolean;
/** Whether cancellation is currently in progress. */
cancelLoading?: boolean;
/** Accessible label for the stop/cancel control. Defaults to 'Stop response'. */
cancelLabel?: string;
/** Placeholder text. Defaults to 'Type a message...'. */
placeholder?: string;
/** Maximum number of rows the textarea auto-grows to. Defaults to 6. */
Expand All @@ -29,7 +37,11 @@ export interface ChatInputProps {
*/
export function ChatInput({
onSend,
onCancel,
disabled = false,
showCancel = false,
cancelLoading = false,
cancelLabel = 'Stop response',
placeholder = 'Type a message...',
maxRows = 6,
accept = 'image/*,video/*',
Expand Down Expand Up @@ -181,6 +193,18 @@ export function ChatInput({
rows={1}
aria-label={placeholder}
/>
{showCancel && (
<button
className={`${baseClass}__cancel`}
type="button"
onClick={onCancel}
disabled={cancelLoading}
aria-label={cancelLabel}
title={cancelLabel}
>
<StopIcon />
</button>
)}
<button
className={`${baseClass}__send`}
type="submit"
Expand Down Expand Up @@ -240,6 +264,14 @@ function SendIcon() {
);
}

function StopIcon() {
return (
<svg className="ec-chat-input__stop-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<rect x="7" y="7" width="10" height="10" rx="1" fill="currentColor" stroke="none" />
</svg>
);
}

function AttachIcon() {
return (
<svg
Expand Down
8 changes: 7 additions & 1 deletion src/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export function ChatMessage({
const isUser = message.role === 'user';
const baseClass = 'ec-chat-message';
const roleClass = isUser ? `${baseClass}--user` : `${baseClass}--assistant`;
const classes = [baseClass, roleClass, className].filter(Boolean).join(' ');
const statusClass = message.deliveryStatus ? `${baseClass}--${message.deliveryStatus}` : '';
const classes = [baseClass, roleClass, statusClass, className].filter(Boolean).join(' ');

const hasText = message.content.trim().length > 0;
const hasAttachments = message.attachments && message.attachments.length > 0;
Expand All @@ -59,6 +60,11 @@ export function ChatMessage({
{formatTime(message.timestamp)}
</time>
)}
{message.deliveryStatus && (
<span className={`${baseClass}__status`}>
{message.deliveryStatus === 'queued' ? 'Queued' : 'Failed'}
</span>
)}
</div>
);
}
Expand Down
12 changes: 11 additions & 1 deletion src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
export { useChat, type UseChatOptions, type UseChatReturn } from './useChat.ts';
export {
useChat,
type ChatRun,
type CancelRunInput,
type ChatRunCapabilities,
type ChatRunStatus,
type QueueMessageInput,
type QueueMessageResult,
type UseChatOptions,
type UseChatReturn,
} from './useChat.ts';
export {
useLoadingMessages,
DEFAULT_LOADING_MESSAGES,
Expand Down
Loading