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
5 changes: 5 additions & 0 deletions .changeset/poweredby-link-toggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@smooai/chat-widget': patch
---

The "powered by smooth-operator" tag (full-page header) and footer now link to the smooth-operator GitHub repo (opens in a new tab). Added a `hide-branding` element attribute (`hideBranding` config key) to hide the branding in both render paths; branding is shown by default. The "Restore my chats" footer affordance is preserved independently of the toggle.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ Declarative attributes mirror the programmatic [`ChatWidgetConfig`](./src/config
| `greeting` | `greeting` | Shown before the first message. |
| `mode` | `mode` | `popover` (default) or `fullpage`. |
| `start-open` | `startOpen` | Open the panel immediately (no launcher click). |
| `hide-branding` | `hideBranding` | Hide the "powered by smooth-operator" tag + footer link. Default shown. |
| — | `theme` | Brand colors (see [Theming](#theming)). |
| — | `userName` / `userEmail` | Optional participant identity. |

Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ export interface ChatWidgetConfig {
connectionErrorMessage?: string;
/** Start the panel open instead of collapsed to the launcher. */
startOpen?: boolean;
/**
* Hide the "powered by smooth-operator" branding in the header tag and the
* composer footer. Defaults to `false` (branding shown). The `hide-branding`
* HTML attribute maps to this.
*/
hideBranding?: boolean;
/**
* Suggested starter prompts shown as clickable chips before the first message.
* Clicking one sends it. Capped at 5 for layout.
Expand Down Expand Up @@ -172,6 +178,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
greeting: config.greeting ?? 'Hi! How can I help you today?',
connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
startOpen: config.startOpen ?? false,
hideBranding: config.hideBranding ?? false,
examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
requireName: config.requireName ?? false,
requireEmail: config.requireEmail ?? false,
Expand Down
63 changes: 63 additions & 0 deletions src/element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,69 @@ describe('<smooth-agent-chat> render', () => {
});
});

describe('powered-by branding (link + hide-branding toggle)', () => {
const REPO_URL = 'https://github.com/SmooAI/smooth-operator';

afterEach(() => {
document.body.innerHTML = '';
});

function mount(attrs: Record<string, string>): HTMLElement {
defineChatWidget();
const el = document.createElement(ELEMENT_TAG);
el.setAttribute('endpoint', 'wss://e/ws');
el.setAttribute('agent-id', 'a1');
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
document.body.appendChild(el);
return el;
}
function mountCfg(cfg: Record<string, unknown>): HTMLElement {
defineChatWidget();
const el = document.createElement(ELEMENT_TAG) as HTMLElement & { configure: (c: Record<string, unknown>) => void };
el.setAttribute('endpoint', 'wss://e/ws');
el.setAttribute('agent-id', 'a1');
el.configure(cfg);
document.body.appendChild(el);
return el;
}

it('by default links the composer footer to the smooth-operator repo (new tab)', () => {
const link = mount({}).shadowRoot!.querySelector('.footer a') as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.getAttribute('href')).toBe(REPO_URL);
expect(link?.getAttribute('target')).toBe('_blank');
expect(link?.getAttribute('rel')).toContain('noopener');
expect(link?.textContent).toContain('smooth');
});

it('by default shows a linked "powered by" tag in the full-page header', () => {
const tag = mount({ mode: 'fullpage' }).shadowRoot!.querySelector('.powered') as HTMLAnchorElement | null;
expect(tag).not.toBeNull();
expect(tag?.tagName).toBe('A');
expect(tag?.getAttribute('href')).toBe(REPO_URL);
expect(tag?.getAttribute('target')).toBe('_blank');
});

it('hide-branding attribute drops the header tag and the footer branding link (full-page)', () => {
const sr = mount({ mode: 'fullpage', 'hide-branding': '' }).shadowRoot!;
expect(sr.querySelector('.powered')).toBeNull();
// No link to the repo anywhere in the footer.
expect(sr.querySelector('.footer a')).toBeNull();
});

it('hideBranding via configure() hides branding but keeps the "Restore my chats" affordance', () => {
const sr = mountCfg({ hideBranding: true }).shadowRoot!;
expect(sr.querySelector('.footer a')).toBeNull();
expect(sr.querySelector('.restore-link')).not.toBeNull();
});

it('hide-branding with restore disabled omits the footer entirely (popover)', () => {
const sr = mountCfg({ hideBranding: true, allowChatRestore: false }).shadowRoot!;
expect(sr.querySelector('.powered')).toBeNull();
expect(sr.querySelector('.footer')).toBeNull();
});
});

describe('fullpage container sizing (composer-clip regression)', () => {
afterEach(() => {
document.body.innerHTML = '';
Expand Down
24 changes: 20 additions & 4 deletions src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ function phoneToE164(value: string): string | null {
}
}

const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode'] as const;
/** Public smooth-operator repo — the "powered by" header tag + footer link here. */
const SMOOTH_OPERATOR_URL = 'https://github.com/SmooAI/smooth-operator';

const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding'] as const;

/**
* Inline SVG icons (static, trusted strings — never interpolated with user data).
Expand Down Expand Up @@ -204,6 +207,7 @@ export class SmoothAgentChatElement extends HTMLElement {
greeting: this.overrides.greeting ?? this.getAttribute('greeting') ?? undefined,
connectionErrorMessage: this.overrides.connectionErrorMessage,
startOpen: this.overrides.startOpen ?? this.hasAttribute('start-open'),
hideBranding: this.overrides.hideBranding ?? this.hasAttribute('hide-branding'),
examplePrompts: this.overrides.examplePrompts,
requireName: this.overrides.requireName,
requireEmail: this.overrides.requireEmail,
Expand Down Expand Up @@ -282,7 +286,11 @@ export class SmoothAgentChatElement extends HTMLElement {
<span class="title">${escapeHtml(resolved.agentName)}</span>
<span class="status"><span class="dot off"></span><span class="status-text"></span></span>
</div>
<span class="powered">powered by smooth-operator</span>
${
resolved.hideBranding
? ''
: `<a class="powered" href="${SMOOTH_OPERATOR_URL}" target="_blank" rel="noopener noreferrer">powered by smooth-operator</a>`
}
</div>`
: `<div class="header">
<div class="avatar">${monogram}</div>
Expand Down Expand Up @@ -329,7 +337,15 @@ export class SmoothAgentChatElement extends HTMLElement {
<button type="submit" class="pc-submit">Start chat</button>
</form>
</div>`;
const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : '';
// Footer: optional "powered by" branding (hidden by hide-branding) and an
// optional "Restore my chats" affordance. The " · " separator only appears
// when both are present, and the footer is omitted entirely when neither is.
const brandingHtml = resolved.hideBranding
? ''
: `<a href="${SMOOTH_OPERATOR_URL}" target="_blank" rel="noopener noreferrer">powered by <b>smooth&#8209;operator</b></a>`;
const restoreBtn = this.allowChatRestore ? `<button type="button" class="restore-link">Restore my chats</button>` : '';
const footerInner = [brandingHtml, restoreBtn].filter(Boolean).join(' · ');
const footerHtml = footerInner ? `<div class="footer">${footerInner}</div>` : '';
const chatHtml = `
<div class="messages"></div>
<div class="interrupt hidden"></div>
Expand All @@ -338,7 +354,7 @@ export class SmoothAgentChatElement extends HTMLElement {
<textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
<button class="send" type="button" aria-label="Send message">${ICON.send}</button>
</div>
<div class="footer">powered by <b>smooth&#8209;operator</b>${restoreLink}</div>
${footerHtml}
</div>`;

const container = document.createElement('div');
Expand Down
5 changes: 4 additions & 1 deletion src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,8 @@ ${
}
.close:hover { background: color-mix(in srgb, var(--sac-text) 12%, transparent); transform: translateY(1px); }
.close svg { width: 16px; height: 16px; opacity: .8; }
.powered { margin-left: auto; font-size: 10.5px; letter-spacing: .02em; opacity: .6; }
.powered { margin-left: auto; font-size: 10.5px; letter-spacing: .02em; opacity: .6; color: inherit; text-decoration: none; }
.powered:hover { opacity: .85; text-decoration: underline; }
.header-sep { height: 1px; margin: 0 16px; background: linear-gradient(90deg, transparent, var(--sac-border), transparent); }

/* Full-page header: taller, logo-led, no close. */
Expand Down Expand Up @@ -511,6 +512,8 @@ ${
color: color-mix(in srgb, var(--sac-text) 38%, transparent);
}
.footer b { font-weight: 600; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
.footer a { color: inherit; text-decoration: none; }
.footer a:hover { text-decoration: underline; }

/* ─────────────────── Pre-chat identity form ───────────────────────── */
.prechat { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 18px; padding: 22px 20px; }
Expand Down
Loading