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
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Publish @chativa/react
run: pnpm publish --access public --no-git-checks
working-directory: packages/react
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
Expand Down
12 changes: 12 additions & 0 deletions examples/react-vite/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@chativa/react example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
28 changes: 28 additions & 0 deletions examples/react-vite/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "chativa-example-react-vite",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Living usage doc for @chativa/react — <ChativaProvider> + <ChatIva> wired to @chativa/connector-dummy.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@chativa/connector-dummy": "workspace:*",
"@chativa/core": "workspace:*",
"@chativa/genui": "workspace:*",
"@chativa/react": "workspace:*",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "~5.8.3",
"vite": "^7.0.3"
}
}
139 changes: 139 additions & 0 deletions examples/react-vite/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { useCallback, useMemo } from "react";
import { ChativaProvider, ChatIva, ChatBotButton } from "@chativa/react";
import { DummyConnector } from "@chativa/connector-dummy";
import { EventBus, chatStore, type ConnectorStatus } from "@chativa/core";
// Side-effect imports: register the custom GenUI components (see those
// files for why they're plain HTMLElement, not Lit or React).
import "./CustomWeatherCard";
import "./CustomPollCard";

/**
* `<ChatIva>` is only the chat *panel* — it has no launcher of its own.
* `<ChatBotButton>` is the toggle, and its default slot lets you swap the
* built-in gradient circle for a fully custom element. This is the "custom
* chatbot element" pattern: whatever you put inside `<ChatBotButton>` becomes
* the launcher, while `<ChatBotButton>` itself still handles positioning and
* the open/close click — you don't have to reimplement that part.
*/
function CustomLauncher() {
return (
<button className="custom-launcher" aria-label="Open chat">
<span className="custom-launcher-icon" aria-hidden="true">
💬
</span>
Ask Iva
</button>
);
}

export default function App() {
// A stable instance — re-creating the connector on every render would
// register a new one each time and throw ("already registered").
// connectDelay: 0 skips DummyConnector's default 2s fake-handshake delay,
// since this demo's trigger button already waits for a real "connected"
// status rather than guessing at a timeout.
const dummy = useMemo(() => new DummyConnector({ replyDelay: 500, connectDelay: 0 }), []);

// The chat engine only connects (and DummyConnector only registers its
// GenUI handler) once the panel has been opened at least once — that's
// when `<ChatIva>` lazily calls `connector.connect()`. Open the panel and
// wait for `connector_status_changed: "connected"` before running `fn`,
// instead of guessing at a delay.
const runOnceConnected = useCallback((fn: () => void) => {
if (chatStore.getState().connectorStatus === "connected") {
fn();
chatStore.getState().open();
return;
}
const onStatus = (payload: { status: ConnectorStatus }) => {
if (payload.status !== "connected") return;
EventBus.off("connector_status_changed", onStatus);
fn();
};
EventBus.on("connector_status_changed", onStatus);
chatStore.getState().open();
}, []);

const triggerWeatherDemo = useCallback(() => {
runOnceConnected(() => dummy.triggerGenUI("weather"));
}, [dummy, runOnceConnected]);

// Unlike the weather demo (DummyConnector's own built-in stream), this
// builds the GenUI chunk list by hand and injects it directly — the
// question/options *payload* is passed in at the call site below, not
// hardcoded inside the component or a connector method.
const triggerPollDemo = useCallback(
(payload: { question: string; options: string[] }) => {
runOnceConnected(() => {
dummy.injectMessage({
type: "genui",
from: "bot",
data: {
chunks: [{ type: "ui", component: "poll", props: payload, id: 1 }],
streamingComplete: true,
},
});
});
},
[dummy, runOnceConnected],
);

return (
<ChativaProvider
connector={dummy}
theme={{
colors: { primary: "#7c3aed", secondary: "#4f46e5" },
// Hide the (custom) launcher while the panel is open — otherwise
// both would be visible at once.
hideButtonOnOpen: true,
}}
>
<main className="page">
<h1>@chativa/react example</h1>
<p>
<code>ChativaProvider</code> registers the connector + theme once;
<code> ChatBotButton</code> renders a custom launcher element (see{" "}
<code>CustomLauncher</code> below) instead of the default icon;{" "}
<code>ChatIva</code> is the panel it opens.
</p>
<p>
<code>CustomWeatherCard</code> is a custom GenUI component — a
plain Custom Element (no Lit, no React) registered via{" "}
<code>GenUIRegistry.register(&quot;weather&quot;, ...)</code>.
Click below to open the chat and stream one in.
</p>
<button className="trigger-genui" onClick={triggerWeatherDemo}>
Trigger custom GenUI (weather)
</button>
<p>
<code>CustomPollCard</code> is a second, interactive custom GenUI
component — clicking an option calls the injected{" "}
<code>sendEvent()</code>, which reaches the connector via{" "}
<code>ChatEngine.receiveComponentEvent()</code>.
</p>
<button
className="trigger-genui"
onClick={() =>
triggerPollDemo({
question: "How's this example so far?",
options: ["🎉 Great", "🤔 Needs work", "🙁 Confusing"],
})
}
>
Trigger custom GenUI (poll)
</button>
</main>

<ChatBotButton>
<CustomLauncher />
</ChatBotButton>

<ChatIva
onMessage={(message) => console.log("[chativa] message received:", message)}
onMessageSent={(message) => console.log("[chativa] message sent:", message)}
onWidgetOpen={() => console.log("[chativa] widget opened")}
onWidgetClose={() => console.log("[chativa] widget closed")}
/>
</ChativaProvider>
);
}
118 changes: 118 additions & 0 deletions examples/react-vite/src/CustomPollCard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { GenUIRegistry } from "@chativa/genui";

/**
* A second, distinct custom GenUI component — interactive rather than
* display-only like `CustomWeatherCard`. Demonstrates `sendEvent`, which
* `<genui-message>` injects onto every GenUI component instance right after
* constructing it (before props are assigned): calling it dispatches a
* `genui-send-event` DOM event that bubbles up through `<ChatIva>` to
* `ChatEngine.receiveComponentEvent()` → `connector.receiveComponentEvent()`
* — the same path a real backend-driven form submit or button click uses.
*
* Also plain `HTMLElement` — see `CustomWeatherCard.ts` for why.
*/

const STYLE = `
:host { display: block; }
.card {
max-width: 280px;
padding: 16px 18px;
border-radius: 14px;
background: #ffffff;
border: 1px solid #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.question { font-size: 0.9375rem; font-weight: 600; color: #0f172a; margin-bottom: 12px; }
.options { display: flex; flex-direction: column; gap: 8px; }
button.option {
text-align: left;
border: 1px solid #c7d2fe;
border-radius: 8px;
padding: 8px 12px;
background: #eef2ff;
color: #4338ca;
font-size: 0.875rem;
cursor: pointer;
}
button.option:hover { background: #e0e7ff; }
.voted { font-size: 0.875rem; color: #4338ca; }
.voted strong { color: #0f172a; }
`;

/** Props assigned onto the instance — same shape as the `AIChunkUI.props` sent for this component. */
interface PollProps {
question: string;
options: string[];
}

const DEFAULTS: PollProps = { question: "", options: [] };

export class CustomPollCard extends HTMLElement {
#state: PollProps = { ...DEFAULTS };
#votedOption: string | null = null;
#root: ShadowRoot;

/** Injected by `<genui-message>` right after construction — see the file doc comment. */
sendEvent?: (type: string, payload: unknown) => void;

constructor() {
super();
this.#root = this.attachShadow({ mode: "open" });

for (const key of Object.keys(DEFAULTS) as (keyof PollProps)[]) {
Object.defineProperty(this, key, {
get: () => this.#state[key],
set: (value) => {
(this.#state[key] as unknown) = value;
this.#render();
},
});
}
}

connectedCallback() {
this.#render();
}

#vote(option: string) {
if (this.#votedOption) return;
this.#votedOption = option;
this.sendEvent?.("poll_vote", { option });
this.#render();
}

#render() {
const { question, options } = this.#state;

if (this.#votedOption) {
this.#root.innerHTML = `<style>${STYLE}</style><div class="card"><div class="voted">Thanks for voting: <strong></strong></div></div>`;
this.#root.querySelector("strong")!.textContent = this.#votedOption;
return;
}

this.#root.innerHTML = `
<style>${STYLE}</style>
<div class="card">
<div class="question"></div>
<div class="options"></div>
</div>
`;
this.#root.querySelector(".question")!.textContent = question;

const optionsEl = this.#root.querySelector(".options")!;
for (const option of options) {
const button = document.createElement("button");
button.className = "option";
button.type = "button";
button.textContent = option;
button.addEventListener("click", () => this.#vote(option));
optionsEl.appendChild(button);
}
}
}

if (!customElements.get("demo-poll-card")) {
customElements.define("demo-poll-card", CustomPollCard);
}

GenUIRegistry.register("poll", CustomPollCard);
Loading
Loading