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
2 changes: 1 addition & 1 deletion assets/dist/roundtable.css

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions assets/dist/roundtable.js

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions assets/src/chat-copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export function resolveInitialMessage(initialMessage, agentName, projectName) {
return initialMessage || chatGreeting(agentName, projectName);
}

/** Calm notice shown while the community chat is paused hub-side. */
export const CHAT_PAUSED_NOTICE = 'The community chat is temporarily paused.';

/** Opening copy when the user starts a new topic. */
export const NEW_TOPIC_INTRO =
"Let's start a new topic. Tell me what's going on — a question, something that's broken, or a feature you'd like — and I'll shape it into a clear topic you can review & publish to the community.";
Expand Down
4 changes: 2 additions & 2 deletions assets/src/community-app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { TopicDetail } from './topic-detail.jsx';
import { PublishDialog } from './publish-dialog.jsx';
import { publishCase } from './api.js';
import { fetchTabCounts } from './tab-counts.js';
import { agentDisplayName, projectDisplayName } from './config.js';
import { agentDisplayName, betaEnabled, projectDisplayName } from './config.js';

const TABS = [
{ id: 'all', label: 'All', enabled: true },
Expand Down Expand Up @@ -80,7 +80,7 @@ export function CommunityApp() {
<div class="rt-list-shell">
<div class="rt-pagehead">
<div>
<h1 class="rt-title">Community</h1>
<h1 class="rt-title">Community{betaEnabled() && <span class="rt-beta-pill">Beta</span>}</h1>
{projectName && <p class="rt-sub">{projectName}</p>}
</div>
<button class="rt-newtopic-head" type="button" onClick={requestNewTopic}>+ New topic</button>
Expand Down
46 changes: 34 additions & 12 deletions assets/src/components.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import { canDraftTopic, turnsToConversation } from './conversation.js';
import { rehydratedTopicWorthy, rehydratedTurns } from './rehydrate.js';
import { PublishDialog } from './publish-dialog.jsx';
import { AgentAvatar } from './avatars.jsx';
import { agentDisplayName, initialMessage, projectDisplayName } from './config.js';
import { agentDisplayName, betaEnabled, chatDisabled, initialMessage, projectDisplayName } from './config.js';
import { labelStep } from './steps.js';
import { NEW_TOPIC_INTRO, resolveInitialMessage, TOPIC_CHIPS } from './chat-copy.js';
import { CHAT_PAUSED_NOTICE, NEW_TOPIC_INTRO, resolveInitialMessage, TOPIC_CHIPS } from './chat-copy.js';

const STREAM_FIRST_EVENT_TIMEOUT_MS = 15000;

Expand All @@ -31,6 +31,9 @@ const STREAM_FIRST_EVENT_TIMEOUT_MS = 15000;
* @returns {string}
*/
function errorText(kind) {
if (kind === 'chat_disabled') {
return CHAT_PAUSED_NOTICE;
}
if (kind === 'licence_invalid' || kind === 'http_402') {
return 'Your product licence could not be verified. Check your licence in the plugin settings, then try again.';
}
Expand Down Expand Up @@ -326,7 +329,17 @@ function useTypewriter(text, active) {
textRef.current = text;

useEffect(() => {
if (!animateRef.current) return undefined;
if (!animateRef.current) {
// A non-animating bubble must always show its full text. When the panel
// swaps the text on a reused component (the "+ New topic" reset replaces
// the greeting in place), the stale `shown` from the previous text would
// otherwise clip the new one mid-sentence.
if (shownRef.current !== text.length) {
shownRef.current = text.length;
setShown(text.length);
}
return undefined;
}
if (rafRef.current != null || shownRef.current >= text.length) return undefined;

let lastTime = null;
Expand Down Expand Up @@ -363,8 +376,11 @@ function useTypewriter(text, active) {
function TypedBubble({ turn, streaming }) {
const shown = useTypewriter(turn.reply, streaming);
const text = turn.reply.slice(0, shown);
// A paused chat is not the user's fault: keep the calm agent-bubble look,
// not the error styling.
const paused = turn.errorKind === 'chat_disabled';
return (
<div class={'rt-ab' + (turn.error ? ' rt-ab-error' : '')}
<div class={'rt-ab' + (turn.error && !paused ? ' rt-ab-error' : '')}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: turn.error ? errorText(turn.errorKind) : renderMarkdown(text) }} />
);
Expand Down Expand Up @@ -508,10 +524,15 @@ export function computeShowDraftPrompt({
}

export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
// Config-driven pause: the hub's /project/config said the chat is off. Show
// the calm notice as the only bubble and keep the composer inert.
const paused = chatDisabled();
const [newTopicMode, setNewTopicMode] = useState(false);
const [turns, setTurns] = useState([{
role: 'agent', reply: resolveInitialMessage(initialMessage(), agentDisplayName(), projectDisplayName()), steps: [], error: false, introChips: true,
}]);
const [turns, setTurns] = useState([paused
? { role: 'agent', reply: CHAT_PAUSED_NOTICE, steps: [], error: false }
: {
role: 'agent', reply: resolveInitialMessage(initialMessage(), agentDisplayName(), projectDisplayName()), steps: [], error: false, introChips: true,
}]);
const [composer, setComposer] = useState('');
const [busy, setBusy] = useState(false);
const [topicDraft, setTopicDraft] = useState(null);
Expand Down Expand Up @@ -582,6 +603,7 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
}, [turns]);

useEffect(() => {
if (paused) return undefined;
let cancelled = false;
(async () => {
const res = await fetchHistory();
Expand All @@ -605,7 +627,7 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
async function send(textOverride) {
userInteractedRef.current = true;
const text = (textOverride ?? composer).trim();
if (!text || busy || draftBusy) return;
if (!text || busy || draftBusy || paused) return;
// Sending a message is an unambiguous "I want to see the reply": re-stick
// regardless of any stale unstick from an earlier turn's scrolling.
stickToBottomRef.current = { pending: 0, stick: true };
Expand All @@ -615,7 +637,7 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {

async function newTopic() {
userInteractedRef.current = true;
if (busy || draftBusy) return;
if (busy || draftBusy || paused) return;
stickToBottomRef.current = { pending: 0, stick: true };
setBusy(true);
const res = await resetChat();
Expand Down Expand Up @@ -677,7 +699,7 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
<div class="rt-head">
<AgentAvatar class="rt-avatar" />
<div class="rt-head-text">
<b>{agentName}</b>
<b>{agentName}{betaEnabled() && <span class="rt-beta-pill">Beta</span>}</b>
<span>Community assistant</span>
</div>
<span class="rt-head-grow" />
Expand All @@ -699,10 +721,10 @@ export function ChatPanel({ resetNonce = 0, onTopicPublished }) {
)}
<div class="rt-composer">
<div class="rt-cbox">
<textarea ref={composerRef} rows="1" placeholder={composerPlaceholder} value={composer}
<textarea ref={composerRef} rows="1" placeholder={composerPlaceholder} value={composer} disabled={paused}
onInput={(e) => setComposer(e.currentTarget.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }} />
<button class="rt-send-icon" type="button" disabled={busy || draftBusy} onClick={() => send()} aria-label="Send">➤</button>
<button class="rt-send-icon" type="button" disabled={busy || draftBusy || paused} onClick={() => send()} aria-label="Send">➤</button>
</div>
<div class="rt-chint">Enter to send · Shift+Enter for a new line</div>
</div>
Expand Down
29 changes: 28 additions & 1 deletion assets/src/config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// assets/src/config.js — the names localized by src/Admin/Page.php.

/** @returns {{agentName?:string, projectName?:string, initialMessage?:string}} */
/** @returns {{agentName?:string, projectName?:string, initialMessage?:string, beta?:boolean, chatDisabled?:boolean}} */
function cfg() {
return (typeof window !== 'undefined' && window.RoundtableConfig) || {};
}
Expand Down Expand Up @@ -28,3 +28,30 @@ export function projectDisplayName() {
export function initialMessage() {
return cfg().initialMessage || '';
}

/**
* Read a boolean config flag. wp_localize_script stringifies scalars (true
* becomes "1", false becomes ""), so a strict === true check silently fails
* against a real WordPress payload; accept the stringified truthy forms too.
* @param {unknown} value
* @returns {boolean}
*/
function flag(value) {
return value === true || value === 1 || value === '1' || value === 'true';
}

/**
* Whether the consuming plugin flagged this integration as beta ("Beta" pill in the UI).
* @returns {boolean}
*/
export function betaEnabled() {
return flag(cfg().beta);
}

/**
* Whether the hub reported the community chat as paused for this project.
* @returns {boolean}
*/
export function chatDisabled() {
return flag(cfg().chatDisabled);
}
25 changes: 25 additions & 0 deletions assets/src/markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,29 @@ const md = new MarkdownIt({
*/
export function renderMarkdown(src) {
return md.render(typeof src === 'string' ? src : '');
}

/**
* Reduce markdown to a plain-text excerpt for list cards: drops structural
* markers (heading hashes, emphasis, code fences/backticks, list markers,
* blockquotes, horizontal rules), keeps link/image text, and collapses all
* whitespace into single spaces.
* @param {string} src markdown source
* @returns {string} plain text
*/
export function markdownToPlainText(src) {
const text = typeof src === 'string' ? src : '';
return text
.replace(/^```[^\n]*$/gm, '') // code fence open/close lines (content is kept)
.replace(/^#{1,6}\s+/gm, '') // heading markers
.replace(/^\s*>\s?/gm, '') // blockquote markers
.replace(/^\s*([-*_]\s*){3,}$/gm, '') // horizontal rules
.replace(/^\s*(?:[-*+]|\d{1,3}[.)])\s+/gm, '') // list markers
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt text
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> link text
.replace(/(\*\*|__)(.*?)\1/g, '$2') // bold markers
.replace(/(\*|_)(.*?)\1/g, '$2') // italic markers
.replace(/`([^`]*)`/g, '$1') // inline code backticks
.replace(/\s+/g, ' ')
.trim();
}
8 changes: 7 additions & 1 deletion assets/src/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion assets/src/topic-list.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { fetchCases } from './api.js';
import { markdownToPlainText } from './markdown.js';
import { mapCaseToTopic } from './topics.js';
import { VoteControl } from './vote-control.jsx';

Expand All @@ -24,7 +25,7 @@ export function TopicRow({ topic, onSelect, hideStatus = false, onVoteChange })
}}
>
<span class="rt-ctitle">{topic.title}</span>
<div class="rt-csnip">{topic.snippet}</div>
<div class="rt-csnip">{markdownToPlainText(topic.snippet)}</div>
<div class="rt-cmeta">
<span class={'rt-badge ' + topic.typeClass}>{topic.typeLabel}</span>
{!hideStatus && <span class={'rt-status ' + topic.statusClass}>{topic.statusLabel}</span>}
Expand Down
12 changes: 12 additions & 0 deletions assets/test/community-app.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,15 @@ test('CommunityApp renders no sub-line when no project name is configured', () =
const html = renderToString(h(CommunityApp, {}));
assert.doesNotMatch(html, /rt-sub/);
});

test('CommunityApp renders a Beta pill next to the Community heading when beta is configured', () => {
globalThis.window = { RoundtableConfig: { agentName: 'Nova', beta: true } };
const html = renderToString(h(CommunityApp, {}));
assert.match(html, /rt-title[^>]*>Community<span class="rt-beta-pill">Beta<\/span><\/h1>/);
});

test('CommunityApp renders no Beta pill when beta is absent', () => {
globalThis.window = { RoundtableConfig: { agentName: 'Nova' } };
const html = renderToString(h(CommunityApp, {}));
assert.doesNotMatch(html, /rt-beta-pill/);
});
53 changes: 53 additions & 0 deletions assets/test/components.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ test('ChatPanel renders the configured agent name in header, greeting and compos
assert.match(html, /Report a bug/);
});

test('ChatPanel renders a Beta pill next to the agent name when beta is configured', async () => {
const { ChatPanel } = await import('../src/components.jsx');
globalThis.window = { RoundtableConfig: { agentName: 'Nova', beta: true } };
const html = renderToString(h(ChatPanel, {}));
assert.match(html, /<b>Nova<span class="rt-beta-pill">Beta<\/span><\/b>/);
});

test('ChatPanel renders no Beta pill when beta is absent', async () => {
const { ChatPanel } = await import('../src/components.jsx');
globalThis.window = { RoundtableConfig: { agentName: 'Nova' } };
const html = renderToString(h(ChatPanel, {}));
assert.doesNotMatch(html, /rt-beta-pill/);
});

test('ChatPanel renders the configured initial message instead of the generated greeting when set', async () => {
const { ChatPanel } = await import('../src/components.jsx');
globalThis.window = {
Expand All @@ -42,6 +56,45 @@ test('ChatPanel renders the configured initial message instead of the generated
assert.doesNotMatch(html, /Ask how Acme Plugin works/);
});

test('ChatPanel with chatDisabled shows the paused notice and an inert composer', async () => {
const { ChatPanel } = await import('../src/components.jsx');
globalThis.window = { RoundtableConfig: { agentName: 'Nova', chatDisabled: true } };
const html = renderToString(h(ChatPanel, {}));
assert.match(html, /The community chat is temporarily paused\./);
assert.match(html, /rt-ab/);
assert.doesNotMatch(html, /rt-ab-error/);
assert.doesNotMatch(html, /rt-chips/);
assert.doesNotMatch(html, /Ask how/);
assert.match(html, /<textarea[^>]*\bdisabled\b/);
assert.match(html, /aria-label="Send"[^>]*\bdisabled\b|\bdisabled\b[^>]*aria-label="Send"/);
});

test('ChatPanel without chatDisabled keeps the composer enabled and shows no paused notice', async () => {
const { ChatPanel } = await import('../src/components.jsx');
globalThis.window = { RoundtableConfig: { agentName: 'Nova' } };
const html = renderToString(h(ChatPanel, {}));
assert.doesNotMatch(html, /temporarily paused/);
assert.doesNotMatch(html, /<textarea[^>]*\bdisabled\b/);
});

test('Thread renders the chat_disabled error kind as the calm paused notice without error styling', () => {
const turns = [{
role: 'agent', reply: '', steps: [], error: true, errorKind: 'chat_disabled', done: true,
}];
const html = renderToString(h(Thread, { turns }));
assert.match(html, /The community chat is temporarily paused\./);
assert.doesNotMatch(html, /rt-ab-error/);
});

test('Thread keeps the error styling for a generic failed turn', () => {
const turns = [{
role: 'agent', reply: '', steps: [], error: true, done: true,
}];
const html = renderToString(h(Thread, { turns }));
assert.match(html, /rt-ab-error/);
assert.match(html, /Something went wrong/);
});

test('Thread renders agent turn with agent avatar', () => {
const turns = [{ role: 'agent', reply: 'hello', steps: [], error: false }];
const html = renderToString(h(Thread, { turns }));
Expand Down
32 changes: 31 additions & 1 deletion assets/test/config.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { agentDisplayName, initialMessage, projectDisplayName } from '../src/config.js';
import { agentDisplayName, betaEnabled, chatDisabled, initialMessage, projectDisplayName } from '../src/config.js';

test('agentDisplayName falls back to the SDK default', () => {
globalThis.window = {};
Expand Down Expand Up @@ -31,3 +31,33 @@ test('initialMessage returns the configured message', () => {
globalThis.window = { RoundtableConfig: { initialMessage: 'Welcome! Ask me anything.' } };
assert.equal(initialMessage(), 'Welcome! Ask me anything.');
});

test('betaEnabled is false when not configured', () => {
globalThis.window = { RoundtableConfig: { agentName: 'Sage' } };
assert.equal(betaEnabled(), false);
});

test('betaEnabled is true when the config flags beta', () => {
globalThis.window = { RoundtableConfig: { beta: true } };
assert.equal(betaEnabled(), true);
});

test('chatDisabled is false when not configured', () => {
globalThis.window = { RoundtableConfig: { agentName: 'Sage' } };
assert.equal(chatDisabled(), false);
});

test('chatDisabled is true when the hub paused the chat', () => {
globalThis.window = { RoundtableConfig: { chatDisabled: true } };
assert.equal(chatDisabled(), true);
});

test('betaEnabled and chatDisabled accept wp_localize_script stringified booleans', async () => {
const { betaEnabled, chatDisabled } = await import('../src/config.js');
globalThis.window = { RoundtableConfig: { beta: '1', chatDisabled: '' } };
assert.equal(betaEnabled(), true);
assert.equal(chatDisabled(), false);
globalThis.window = { RoundtableConfig: { beta: '', chatDisabled: '1' } };
assert.equal(betaEnabled(), false);
assert.equal(chatDisabled(), true);
});
Loading
Loading