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.

36 changes: 18 additions & 18 deletions assets/dist/roundtable.js

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions assets/src/badges.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** @jsx h */
import { h } from 'preact';

/**
* The "Maintainer" badge, rendered next to an author's handle wherever
* `author_role === 'maintainer'` — topic rows, the topic detail header and
* post, and comments. One shared component/class so every surface renders
* the exact same badge, instead of each place inventing its own look.
*/
export function MaintainerBadge() {
return <span class="rt-badge rt-b-maintainer">Maintainer</span>;
}
4 changes: 3 additions & 1 deletion assets/src/community-app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { PublishDialog } from './publish-dialog.jsx';
import { publishCase, submitTopic } from './api.js';
import { fetchTabCounts } from './tab-counts.js';
import {
agentDisplayName, betaEnabled, chatEnabled, projectDisplayName,
agentDisplayName, attribution, betaEnabled, chatEnabled, projectDisplayName,
} from './config.js';

const TABS = [
Expand All @@ -38,6 +38,7 @@ export function CommunityApp() {
const chatOn = chatEnabled();
const agentName = agentDisplayName();
const projectName = projectDisplayName();
const attributionText = attribution();

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -115,6 +116,7 @@ export function CommunityApp() {
</div>
<button class="rt-newtopic-head" type="button" onClick={requestNewTopic}>+ New topic</button>
</div>
{attributionText && <p class="rt-sub rt-attribution">{attributionText}</p>}
<nav class="rt-tabs" aria-label="Community views">
{TABS.map((tab) => (
<button
Expand Down
12 changes: 11 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, beta?:boolean, chatDisabled?:boolean, chatEnabled?:boolean}} */
/** @returns {{agentName?:string, projectName?:string, initialMessage?:string, beta?:boolean, chatDisabled?:boolean, chatEnabled?:boolean, attribution?:string|null}} */
function cfg() {
return (typeof window !== 'undefined' && window.RoundtableConfig) || {};
}
Expand Down Expand Up @@ -65,3 +65,13 @@ export function chatDisabled() {
export function chatEnabled() {
return flag(cfg().chatEnabled);
}

/**
* The host plugin's optional one-line attribution, shown under the page
* header, or '' when it is not configured. The SDK never supplies its own
* text here — this is whatever the host plugin's `Config::$attribution` is.
* @returns {string}
*/
export function attribution() {
return cfg().attribution || '';
}
2 changes: 2 additions & 0 deletions assets/src/styles.css

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

12 changes: 10 additions & 2 deletions assets/src/topic-detail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DisabledVote, VoteControl } from './vote-control.jsx';
import { CommentEditor } from './comment-editor.jsx';
import { CommentActions } from './comment-actions.jsx';
import { htmlHasText } from './wysiwyg-editor.jsx';
import { MaintainerBadge } from './badges.jsx';

function CommentRow({ comment }) {
const Ava = comment.isAgent ? AgentAvatar : PersonAvatar;
Expand All @@ -20,7 +21,9 @@ function CommentRow({ comment }) {
<div class="rt-cm-body">
<div class="rt-cm-who">
<b>{comment.handle}</b>
{comment.roleBadge ? <span class="rt-role-badge">{comment.roleBadge}</span> : null}
{comment.role === 'maintainer' && <MaintainerBadge />}
{comment.role === 'assistant' && comment.roleBadge
? <span class="rt-role-badge">{comment.roleBadge}</span> : null}
{comment.age ? <span class="rt-cm-time">{comment.age}</span> : null}
</div>
{comment.isTombstone
Expand Down Expand Up @@ -124,14 +127,19 @@ export function TopicDetail({ topic, onBack, onVoteChange }) {
<span class={'rt-status ' + topic.statusClass}>{topic.statusLabel}</span>
<span class="rt-dot">·</span>
<span class="rt-handle">{topic.handle}</span>
{topic.authorRole === 'maintainer' && <MaintainerBadge />}
{topic.age ? <span class="rt-age-wrap"><span class="rt-dot">·</span><span>{topic.age}</span></span> : null}
</div>
</div>
</div>
<div class="rt-post">
<PersonAvatar initials={topic.initials} size="sm" class="rt-post-ava" />
<div class="rt-post-body">
<div class="rt-post-who"><b>{topic.handle}</b>{topic.age ? <span> · {topic.age}</span> : null}</div>
<div class="rt-post-who">
<b>{topic.handle}</b>
{topic.authorRole === 'maintainer' && <MaintainerBadge />}
{topic.age ? <span> · {topic.age}</span> : null}
</div>
<div class="rt-prose" dangerouslySetInnerHTML={{ __html: renderMarkdown(topic.snippet) }} />
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions assets/src/topic-list.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fetchCases } from './api.js';
import { markdownToPlainText } from './markdown.js';
import { mapCaseToTopic } from './topics.js';
import { DisabledVote, VoteControl } from './vote-control.jsx';
import { MaintainerBadge } from './badges.jsx';

export function TopicRow({ topic, onSelect, hideStatus = false, onVoteChange }) {
const open = () => onSelect && onSelect(topic);
Expand Down Expand Up @@ -33,6 +34,7 @@ export function TopicRow({ topic, onSelect, hideStatus = false, onVoteChange })
{!hideStatus && <span class={'rt-status ' + topic.statusClass}>{topic.statusLabel}</span>}
<span class="rt-dot">·</span>
<span class="rt-handle">{topic.handle}</span>
{topic.authorRole === 'maintainer' && <MaintainerBadge />}
{topic.age ? <span class="rt-age-wrap"><span class="rt-dot">·</span><span>{topic.age}</span></span> : null}
</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions assets/src/topics.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ export function mapCaseToTopic(row) {
statusClass: isDraft ? DRAFT_STATUS_CLASS : (isPending ? PENDING_STATUS_CLASS : (STATUS_CLASS[status] || 'rt-s-open')),
handle,
initials: handleInitials(handle),
// Defaults to 'community' when absent so the UI works against an older
// hub that doesn't send author_role on case responses yet.
authorRole: row.author_role || 'community',
net: typeof row.net === 'number' ? row.net : 0,
age: formatAge(row.created_at),
isDraft,
Expand Down
10 changes: 10 additions & 0 deletions assets/test/badges.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { h } from 'preact';
import { renderToString } from 'preact-render-to-string';
import { MaintainerBadge } from '../src/badges.jsx';

test('MaintainerBadge renders the "Maintainer" label on the shared badge classes', () => {
const html = renderToString(h(MaintainerBadge, {}));
assert.equal(html, '<span class="rt-badge rt-b-maintainer">Maintainer</span>');
});
21 changes: 21 additions & 0 deletions assets/test/community-app.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,24 @@ test('CommunityApp shows the chat panel when chatEnabled is true', () => {
assert.match(html, /rt-aside/);
assert.match(html, /rt-chat-launch/);
});

test('CommunityApp shows the attribution line under the page header when configured', () => {
globalThis.window = { RoundtableConfig: { agentName: 'Nova', attribution: 'Community powered by Acme' } };
const html = renderToString(h(CommunityApp, {}));
assert.match(html, /rt-attribution[^>]*>Community powered by Acme</);
});

test('CommunityApp renders no attribution line when it is not configured', () => {
globalThis.window = { RoundtableConfig: { agentName: 'Nova' } };
const html = renderToString(h(CommunityApp, {}));
assert.doesNotMatch(html, /rt-attribution/);
});

test('CommunityApp escapes the attribution line as plain text (no live markup)', () => {
globalThis.window = { RoundtableConfig: { agentName: 'Nova', attribution: '<b>Acme</b> & friends' } };
const html = renderToString(h(CommunityApp, {}));
// Preact escapes text children, so the '<' that would open a tag is neutralized —
// no literal <b> element ever lands in the DOM.
assert.doesNotMatch(html, /<b>Acme/);
assert.match(html, /&lt;b>Acme&lt;\/b> &amp; friends/);
});
17 changes: 16 additions & 1 deletion assets/test/config.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
agentDisplayName, betaEnabled, chatDisabled, chatEnabled, initialMessage, projectDisplayName,
agentDisplayName, attribution, betaEnabled, chatDisabled, chatEnabled, initialMessage, projectDisplayName,
} from '../src/config.js';

test('agentDisplayName falls back to the SDK default', () => {
Expand Down Expand Up @@ -80,3 +80,18 @@ test('chatEnabled accepts wp_localize_script stringified booleans', () => {
globalThis.window = { RoundtableConfig: { chatEnabled: '' } };
assert.equal(chatEnabled(), false);
});

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

test('attribution is empty when the boot payload sends null', () => {
globalThis.window = { RoundtableConfig: { attribution: null } };
assert.equal(attribution(), '');
});

test('attribution returns the configured line', () => {
globalThis.window = { RoundtableConfig: { attribution: 'Community powered by Acme' } };
assert.equal(attribution(), 'Community powered by Acme');
});
43 changes: 43 additions & 0 deletions assets/test/topic-detail.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,49 @@ test('TopicDetail renders title and back link', () => {
assert.doesNotMatch(html, /rt-ed-tools/);
});

test('TopicDetail shows a Maintainer badge in the header and post when authorRole is maintainer', () => {
const html = renderToString(h(TopicDetail, {
topic: {
id: 'c1',
title: 'Batch translate',
snippet: 'Times out on large catalogs',
typeLabel: 'Feature',
typeClass: 'rt-b-feat',
statusLabel: 'Open',
statusClass: 'rt-s-open',
handle: 'slate-meadow',
initials: 'SM',
net: 27,
age: '3d',
authorRole: 'maintainer',
},
onBack: () => {},
}));
// Once in the rt-cmeta header, once next to the post's own author line.
assert.equal((html.match(/rt-b-maintainer">Maintainer</g) || []).length, 2);
});

test('TopicDetail shows no Maintainer badge for a community author', () => {
const html = renderToString(h(TopicDetail, {
topic: {
id: 'c1',
title: 'Batch translate',
snippet: 'Times out on large catalogs',
typeLabel: 'Feature',
typeClass: 'rt-b-feat',
statusLabel: 'Open',
statusClass: 'rt-s-open',
handle: 'slate-meadow',
initials: 'SM',
net: 27,
age: '3d',
authorRole: 'community',
},
onBack: () => {},
}));
assert.doesNotMatch(html, /rt-b-maintainer/);
});

test('TopicDetail renders the topic body as full markdown', () => {
const html = renderToString(h(TopicDetail, {
topic: {
Expand Down
18 changes: 18 additions & 0 deletions assets/test/topic-list.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@ test('TopicRow shows the vote arrows disabled (not hidden) for a pending topic',
assert.match(html, /<button[^>]*\bdisabled\b[^>]*aria-label="Downvote"/);
});

test('TopicRow shows a Maintainer badge next to the handle when authorRole is maintainer', () => {
const html = renderToString(h(TopicRow, {
topic: {
id: 'c1', title: 'T', snippet: 's', typeLabel: 'Q', typeClass: 'rt-b-q', statusLabel: 'Open', statusClass: 'rt-s-open', handle: 'teal-owl', net: 0, age: '', authorRole: 'maintainer',
},
}));
assert.match(html, /rt-b-maintainer">Maintainer</);
});

test('TopicRow shows no Maintainer badge for a community author', () => {
const html = renderToString(h(TopicRow, {
topic: {
id: 'c1', title: 'T', snippet: 's', typeLabel: 'Q', typeClass: 'rt-b-q', statusLabel: 'Open', statusClass: 'rt-s-open', handle: 'teal-owl', net: 0, age: '', authorRole: 'community',
},
}));
assert.doesNotMatch(html, /rt-b-maintainer/);
});

test('TopicRow renders a markdown summary as a plain-text excerpt', () => {
const html = renderToString(h(TopicRow, {
topic: {
Expand Down
15 changes: 15 additions & 0 deletions assets/test/topics.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ test('mapCaseToTopic treats pending visibility as awaiting approval', () => {
assert.equal(topic.statusClass, 'rt-s-pending');
});

test('mapCaseToTopic defaults authorRole to community when the hub omits it', () => {
const topic = mapCaseToTopic({
id: 'c4', title: 'No role', summary: 's', type: 'bug', status: 'open', author_handle: 'h', net: 0, created_at: '2026-07-01T12:00:00',
});
assert.equal(topic.authorRole, 'community');
});

test('mapCaseToTopic passes the hub author_role through', () => {
const topic = mapCaseToTopic({
id: 'c5', title: 'From a maintainer', summary: 's', type: 'bug', status: 'open', author_handle: 'h', author_role: 'maintainer', net: 0, created_at: '2026-07-01T12:00:00',
});
assert.equal(topic.authorRole, 'maintainer');
});

test('mapCaseToTopic treats private visibility as draft', () => {
const topic = mapCaseToTopic({
id: 'c2',
Expand Down Expand Up @@ -91,6 +105,7 @@ test('mapCommentToView marks tombstones and renders active comments', () => {

test('resolveCommentRole maps hub roles and legacy subjects', () => {
assert.equal(resolveCommentRole({ author_role: 'assistant' }), 'assistant');
assert.equal(resolveCommentRole({ author_role: 'maintainer' }), 'maintainer');
assert.equal(resolveCommentRole({ subject_id: 'system' }), 'maintainer');
assert.equal(resolveCommentRole({ subject_id: 'agent:sage' }), 'assistant');
assert.equal(resolveCommentRole({}), 'community');
Expand Down
1 change: 1 addition & 0 deletions src/Admin/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public function enqueue( string $hookSuffix ): void {
'RoundtableConfig',
array(
'agentName' => $display['agentName'],
'attribution' => $this->config->attribution,
'beta' => $this->config->beta,
'chatDisabled' => $display['chatDisabled'],
'chatEnabled' => $this->config->enableChat,
Expand Down
4 changes: 4 additions & 0 deletions src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ final class Config {
* and the Community heading.
* @param bool $enableChat Whether the AI chat is available. False (the default) hides
* the chat UI and unregisters the chat/message REST routes.
* @param string|null $attribution Optional one-line attribution shown under the page header
* (e.g. "Community powered by Acme"). Null (the default) hides
* it. The SDK never supplies its own text — the host plugin does.
*/
public function __construct(
public readonly string $projectApiKey,
Expand All @@ -31,6 +34,7 @@ public function __construct(
public readonly string $projectName = '',
public readonly bool $beta = false,
public readonly bool $enableChat = false,
public readonly ?string $attribution = null,
) {
}
}
36 changes: 36 additions & 0 deletions tests/Admin/PageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,42 @@ public function test_enqueue_passes_the_chat_enabled_flag_through_when_configure
(new Page($config, 'tools.php', $hub))->enqueue('tools_page_roundtable-community');
}

public function test_enqueue_passes_the_attribution_line_through_when_configured(): void
{
$this->expectNotToPerformAssertions();
$config = new Config('pk_secret', new FakeConsumer(), 'Sage', attribution: 'Community powered by Acme');
$hub = new HubClient($config, new FakeTransport(200, '{}'));
Functions\when('wp_enqueue_editor')->justReturn(null);
Functions\when('wp_enqueue_style')->justReturn(true);
Functions\when('plugins_url')->justReturn('http://x/wp-content/plugins/host/assets/dist/asset');
Functions\when('rest_url')->justReturn('http://x/wp-json/roundtable/v1');
Functions\when('wp_create_nonce')->justReturn('nonce123');
Functions\expect('wp_enqueue_script')->once();
Functions\expect('wp_localize_script')->once()->with(
'roundtable',
'RoundtableConfig',
\Mockery::on(static fn ($d) => 'Community powered by Acme' === $d['attribution']),
);
(new Page($config, 'tools.php', $hub))->enqueue('tools_page_roundtable-community');
}

public function test_enqueue_omits_attribution_by_default(): void
{
$this->expectNotToPerformAssertions();
Functions\when('wp_enqueue_editor')->justReturn(null);
Functions\when('wp_enqueue_style')->justReturn(true);
Functions\when('plugins_url')->justReturn('http://x/wp-content/plugins/host/assets/dist/asset');
Functions\when('rest_url')->justReturn('http://x/wp-json/roundtable/v1');
Functions\when('wp_create_nonce')->justReturn('nonce123');
Functions\expect('wp_enqueue_script')->once();
Functions\expect('wp_localize_script')->once()->with(
'roundtable',
'RoundtableConfig',
\Mockery::on(static fn ($d) => null === $d['attribution']),
);
$this->page()->enqueue('tools_page_roundtable-community');
}

public function test_enqueue_falls_back_to_local_config_when_hub_call_fails(): void
{
$this->expectNotToPerformAssertions();
Expand Down
8 changes: 8 additions & 0 deletions tests/ConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public function test_exposes_its_values_with_defaults(): void
self::assertSame('', $config->projectName);
self::assertFalse($config->beta);
self::assertFalse($config->enableChat);
self::assertNull($config->attribution);
}

public function test_accepts_overrides(): void
Expand Down Expand Up @@ -55,4 +56,11 @@ public function test_accepts_an_enable_chat_flag(): void

self::assertTrue($config->enableChat);
}

public function test_accepts_an_attribution_line(): void
{
$config = new Config('pk', new FakeConsumer(), attribution: 'Community powered by Acme');

self::assertSame('Community powered by Acme', $config->attribution);
}
}
Loading