@kitn.ai/ui
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
124 lines (110 loc) • 4.84 kB
text/typescript
// Message action-row feedback state — copy + like/dislike — owned ABOVE the
// per-message `<For>` so a streaming re-render (a fresh `messages` array ref per
// chunk) never wipes the user's optimistic vote or the transient "copied" check.
//
// Both facades (`ChatThread` and the `<kai-message>` element) create one of
// these and route their action-bar clicks through `handleAction`. Keeping the
// logic here (not inside the un-unit-testable `defineWebComponent` body) makes
// the toggle/toast/clipboard behavior directly testable.
import { createSignal, onCleanup } from 'solid-js';
import type { ChatMessage, FeedbackVote } from '../elements/chat-types';
import { toast } from './toast-store';
/** Detail shape emitted by the action row. `state` is present only for the
* toggleable feedback votes: `'on'` when a vote is set, `'off'` when cleared.
* Copy / regenerate / edit / custom actions omit it. */
export interface MessageActionDetail {
messageId: string;
action: string;
state?: 'on' | 'off';
}
export interface MessageFeedbackOptions {
/** Emit the `kai-message-action` detail (facade maps this to `dispatch` /
* `onMessageAction`). */
emit: (detail: MessageActionDetail) => void;
/** How long the copy check stays before auto-clearing. Defaults to 2000ms. */
copiedDuration?: number;
/** Container the copy/feedback toasts should be scoped to (the chat, by
* default) so they appear in-chat rather than at the page top. */
target?: () => HTMLElement | undefined;
}
export interface MessageFeedback {
/** Resolve the active vote for a message: a controlled `m.feedback` wins,
* else the facade's own optimistic map. */
resolveFeedback: (m: Pick<ChatMessage, 'id' | 'feedback'>) => FeedbackVote | undefined;
/** Whether a message's copy button should currently show its check. */
isCopied: (id: string) => boolean;
/** Route a clicked action through copy / vote / passthrough handling. */
handleAction: (m: Pick<ChatMessage, 'id' | 'content' | 'feedback'>, action: string) => void;
}
const DEFAULT_COPIED_DURATION = 2000;
/**
* Create the action-row feedback controller for one message list (or one
* standalone message). Owns the optimistic `feedbackMap` + transient
* `copiedIds`, resolves the controlled-wins vote, and turns clicks into
* clipboard writes, toasts, and `kai-message-action` emissions.
*/
export function createMessageFeedback(opts: MessageFeedbackOptions): MessageFeedback {
const [feedbackMap, setFeedbackMap] = createSignal<Record<string, FeedbackVote>>({});
const [copiedIds, setCopiedIds] = createSignal<Set<string>>(new Set());
const copiedDuration = opts.copiedDuration ?? DEFAULT_COPIED_DURATION;
const timers = new Map<string, ReturnType<typeof setTimeout>>();
onCleanup(() => {
timers.forEach((t) => clearTimeout(t));
timers.clear();
});
const resolveFeedback: MessageFeedback['resolveFeedback'] = (m) =>
m.feedback ?? feedbackMap()[m.id];
const isCopied: MessageFeedback['isCopied'] = (id) => copiedIds().has(id);
const markCopied = (id: string) => {
setCopiedIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
const existing = timers.get(id);
if (existing) clearTimeout(existing);
timers.set(id, setTimeout(() => {
timers.delete(id);
setCopiedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
}, copiedDuration));
};
const setVote = (id: string, vote: FeedbackVote | undefined) => {
setFeedbackMap((prev) => {
const next = { ...prev };
if (vote === undefined) delete next[id];
else next[id] = vote;
return next;
});
};
const handleAction: MessageFeedback['handleAction'] = (m, action) => {
if (action === 'copy') {
// Clipboard may be unavailable (insecure context / jsdom); fail soft.
try { navigator.clipboard?.writeText(m.content); } catch { /* ignore */ }
markCopied(m.id);
toast('Copied to clipboard', { target: opts.target?.() });
opts.emit({ messageId: m.id, action });
return;
}
if (action === 'like' || action === 'dislike') {
const current = resolveFeedback(m);
if (current === action) {
// Re-tap clears the vote — no toast on un-vote.
setVote(m.id, undefined);
opts.emit({ messageId: m.id, action, state: 'off' });
} else {
setVote(m.id, action);
toast('Thanks for your feedback', { target: opts.target?.() });
opts.emit({ messageId: m.id, action, state: 'on' });
}
return;
}
// regenerate / edit / custom — passthrough, no state.
opts.emit({ messageId: m.id, action });
};
return { resolveFeedback, isCopied, handleAction };
}