@dbx-tools/ui-teams 0.3.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @dbx-tools/ui-teams
2
+
3
+ React surface for the Teams add-on: render Microsoft Teams Adaptive Cards in the
4
+ browser with the [`adaptivecards`](https://www.npmjs.com/package/adaptivecards)
5
+ JavaScript renderer.
6
+
7
+ Import this package when a UI needs to display the Adaptive Card documents that
8
+ [`@dbx-tools/teams`](../../node/teams) builds. It consumes the browser-safe card
9
+ contract from [`@dbx-tools/shared-teams`](../../shared/teams) and renders through
10
+ [`@dbx-tools/ui-appkit`](../appkit)'s UI kit.
11
+
12
+ **Key features:**
13
+
14
+ - `TeamsChat` - a Teams conversation surface: each turn posts a Bot Framework
15
+ activity to the server's `POST /api/teams/messages` endpoint - the SAME route a
16
+ real Teams channel calls, so this is a faithful preview rather than a parallel
17
+ implementation - and the agent's reply renders as Adaptive Card attachments,
18
+ the way a Teams channel shows a bot's cards. (That route only answers in the
19
+ HTTP response when the server enables its `allowUnauthenticated` development
20
+ bypass; against a real bot registration replies go out over the Connector API,
21
+ so point `endpoint` at `/api/teams/activity` instead.)
22
+ Mints one conversation id per mount (which threads the server's
23
+ agent memory), echoes the user's turn optimistically, cancels an in-flight turn
24
+ on unmount, and offers tap-to-send starters while the transcript is empty.
25
+ - `AdaptiveCardView` - a thin React wrapper over the imperative `adaptivecards`
26
+ renderer: feed it a compiled Adaptive Card document and it mounts the rendered
27
+ node, re-rendering on change and wiring `Action.OpenUrl` clicks (open in a new
28
+ tab by default, or intercept with `onOpenUrl`).
29
+ - `AdaptiveCardGallery` - a self-contained dev tool: edit a `CardSpec` (or pick a
30
+ sample), compile it through the server's `POST /api/teams/card` route, and see
31
+ the card render live. This is the drop-in "display them" surface for a dev
32
+ preview page.
33
+ - Built-in `CARD_SAMPLES` covering the different parts of a card (facts, link
34
+ actions, plain notes) so the preview shows something before you edit.
35
+ - Markdown in a `TextBlock` actually renders. A `TextBlock` is markdown per the
36
+ Adaptive Cards spec, but `adaptivecards` ships no parser - it leaves the
37
+ implementation (and its sanitization) to the host, so `**bold**` renders
38
+ literally until one is installed. Importing this package registers `marked`
39
+ as the renderer's `onProcessMarkdown` processor, degrading to plain text if a
40
+ string fails to parse, and the stylesheet re-applies list markers / paragraph
41
+ spacing that Tailwind's preflight resets away.
42
+
43
+ ## Simulate A Teams Chat
44
+
45
+ ```tsx
46
+ import { TeamsChat } from "@dbx-tools/ui-teams/react";
47
+
48
+ const Chat = () => <TeamsChat className="h-full" />;
49
+ ```
50
+
51
+ Point `endpoint` elsewhere if the plugin is mounted under a different name, and
52
+ pass `agentId` to converse with a specific agent instead of the server's default.
53
+ `starters` replaces the built-in `DEFAULT_STARTERS` prompts.
54
+
55
+ ## Render A Card
56
+
57
+ ```tsx
58
+ import { AdaptiveCardView } from "@dbx-tools/ui-teams/react";
59
+
60
+ const Preview = ({ card }: { card: AdaptiveCard }) => (
61
+ <AdaptiveCardView card={card} className="ac-container" />
62
+ );
63
+ ```
64
+
65
+ `AdaptiveCardView` renders any compiled Adaptive Card document - one returned by
66
+ the `create_teams_card` tool, the plugin's `buildCard` export, or the
67
+ `/api/teams/card` route. Card text is treated as markdown, matching how Teams
68
+ itself renders a `TextBlock`.
69
+
70
+ ## Live Preview Page
71
+
72
+ ```tsx
73
+ import { AdaptiveCardGallery } from "@dbx-tools/ui-teams/react";
74
+
75
+ const Cards = () => <AdaptiveCardGallery />;
76
+ export default Cards;
77
+ ```
78
+
79
+ Drop `<AdaptiveCardGallery/>` on a route. It posts the edited `CardSpec` to the
80
+ `@dbx-tools/teams` plugin's `/api/teams/card` route and renders the compiled
81
+ card, so you exercise the real server builder end to end. Point it at a
82
+ different mount with `buildEndpoint`.
83
+
84
+ ## Styling
85
+
86
+ Import the stylesheet once from the host app's Tailwind entry, after Tailwind and
87
+ your AppKit-UI theme:
88
+
89
+ ```css
90
+ @import "tailwindcss";
91
+ @import "@databricks/appkit-ui/styles.css";
92
+ @import "@dbx-tools/ui-teams/styles.css";
93
+ ```
94
+
95
+ ## Modules
96
+
97
+ - `./react` - `TeamsChat`, `DEFAULT_STARTERS`, `AdaptiveCardView`,
98
+ `AdaptiveCardGallery`, `CARD_SAMPLES`, and the
99
+ re-exported `AdaptiveCard` / `CardResult` / `CardSpec` types.
100
+ - `./styles.css` - the AppKit-token-based styling for the gallery plus a minimal
101
+ Adaptive Card container skin.
package/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+
5
+ export * as reactAdaptiveCard from "./src/react/adaptive-card";
6
+ export * as reactAdaptiveCardGallery from "./src/react/adaptive-card-gallery";
7
+ export * as reactSamples from "./src/react/samples";
8
+ export * as reactTeamsChat from "./src/react/teams-chat";
9
+ export type { AdaptiveCardViewProps } from "./src/react/adaptive-card";
10
+ export type { AdaptiveCardGalleryProps } from "./src/react/adaptive-card-gallery";
11
+ export type { CardSample } from "./src/react/samples";
12
+ export type { TeamsChatProps } from "./src/react/teams-chat";
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@dbx-tools/ui-teams",
3
+ "repository": {
4
+ "type": "git",
5
+ "url": "git+https://github.com/reggie-db/dbx-tools.git",
6
+ "directory": "workspaces/ui/teams"
7
+ },
8
+ "devDependencies": {
9
+ "@types/node": "^24.6.0",
10
+ "@types/react": "^19.2.2",
11
+ "@types/react-dom": "^19.2.2",
12
+ "tsx": "^4.23.0",
13
+ "typescript": "^5.9.3"
14
+ },
15
+ "dependencies": {
16
+ "adaptivecards": "^3.0.5",
17
+ "marked": "^18.0.5",
18
+ "react": "^19.2.4",
19
+ "react-dom": "^19.2.4",
20
+ "@dbx-tools/ui-appkit": "0.3.39",
21
+ "@dbx-tools/shared-teams": "0.3.39",
22
+ "@dbx-tools/shared-core": "0.3.39"
23
+ },
24
+ "license": "UNLICENSED",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "version": "0.3.39",
29
+ "type": "module",
30
+ "exports": {
31
+ "./react": "./src/react/index.ts",
32
+ "./styles.css": "./src/styles.css",
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "index.ts",
37
+ "src"
38
+ ],
39
+ "dbxToolsConfig": {
40
+ "tags": [
41
+ "ui"
42
+ ]
43
+ },
44
+ "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
45
+ "scripts": {
46
+ "build": "projen build",
47
+ "compile": "projen compile",
48
+ "default": "projen default",
49
+ "package": "projen package",
50
+ "post-compile": "projen post-compile",
51
+ "pre-compile": "projen pre-compile",
52
+ "test": "projen test",
53
+ "watch": "projen watch",
54
+ "projen": "projen"
55
+ }
56
+ }
@@ -0,0 +1,175 @@
1
+ // A self-contained dev tool for the Teams add-on: pick or paste a `CardSpec`,
2
+ // send it to the server's `POST /api/teams/card` route to compile it into an
3
+ // Adaptive Card, and render the result with the `adaptivecards` renderer. This
4
+ // is the "have this display them" surface - drop `<AdaptiveCardGallery/>` on a
5
+ // page and you get a live card preview backed by the real server builder.
6
+
7
+ import { json } from "@dbx-tools/shared-core";
8
+ import { card, type CardResult, type CardSpec } from "@dbx-tools/shared-teams";
9
+ import {
10
+ Button,
11
+ Card,
12
+ CardContent,
13
+ CardDescription,
14
+ CardHeader,
15
+ CardTitle,
16
+ Label,
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ Textarea,
23
+ cn,
24
+ } from "@dbx-tools/ui-appkit/react";
25
+ import { useCallback, useEffect, useMemo, useState } from "react";
26
+ import { AdaptiveCardView } from "./adaptive-card";
27
+ import { CARD_SAMPLES } from "./samples";
28
+
29
+ /** Props for {@link AdaptiveCardGallery}. */
30
+ export interface AdaptiveCardGalleryProps {
31
+ /**
32
+ * The server route that compiles a `CardSpec` into an Adaptive Card. Defaults
33
+ * to the `@dbx-tools/teams` plugin's mount (`/api/teams/card`).
34
+ */
35
+ buildEndpoint?: string;
36
+ /** Extra classes merged onto the root container. */
37
+ className?: string;
38
+ }
39
+
40
+ /** Pretty-print a spec for the editable textarea. */
41
+ const format = (spec: CardSpec): string => JSON.stringify(spec, null, 2);
42
+
43
+ /**
44
+ * Live Adaptive Card preview. Edits to the JSON are debounced and posted to the
45
+ * server builder; the returned card is rendered with the `adaptivecards`
46
+ * package. Falls back to a local build only for the initial paint so the panel
47
+ * is never empty while the first request is in flight.
48
+ */
49
+ export const AdaptiveCardGallery = ({
50
+ buildEndpoint = "/api/teams/card",
51
+ className,
52
+ }: AdaptiveCardGalleryProps) => {
53
+ const [sampleIndex, setSampleIndex] = useState(0);
54
+ const [source, setSource] = useState(() => format(CARD_SAMPLES[0]!.spec));
55
+ const [result, setResult] = useState<CardResult | null>(null);
56
+ const [error, setError] = useState<string | null>(null);
57
+
58
+ const spec = useMemo(() => {
59
+ const parsed = card.cardSpecSchema.safeParse(json.parse(source, undefined));
60
+ return parsed.success ? parsed.data : null;
61
+ }, [source]);
62
+
63
+ const build = useCallback(
64
+ async (draft: CardSpec, signal: AbortSignal) => {
65
+ try {
66
+ const response = await fetch(buildEndpoint, {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify(draft),
70
+ signal,
71
+ });
72
+ const payload = await response.json();
73
+ if (!response.ok) {
74
+ setError(payload?.error ?? `Build failed (${response.status})`);
75
+ return;
76
+ }
77
+ const parsed = card.cardResultSchema.safeParse(payload);
78
+ if (!parsed.success) {
79
+ setError("Server returned an unexpected card shape.");
80
+ return;
81
+ }
82
+ setResult(parsed.data);
83
+ setError(null);
84
+ } catch (err) {
85
+ if ((err as Error).name === "AbortError") return;
86
+ setError((err as Error).message);
87
+ }
88
+ },
89
+ [buildEndpoint],
90
+ );
91
+
92
+ useEffect(() => {
93
+ if (!spec) {
94
+ setError("The card JSON is not a valid CardSpec.");
95
+ return;
96
+ }
97
+ const controller = new AbortController();
98
+ const timer = setTimeout(() => void build(spec, controller.signal), 250);
99
+ return () => {
100
+ controller.abort();
101
+ clearTimeout(timer);
102
+ };
103
+ }, [spec, build]);
104
+
105
+ const chooseSample = (value: string) => {
106
+ const index = Number(value);
107
+ setSampleIndex(index);
108
+ setSource(format(CARD_SAMPLES[index]!.spec));
109
+ };
110
+
111
+ return (
112
+ <div className={cn("grid gap-4 md:grid-cols-2", className)}>
113
+ <Card className="min-h-0">
114
+ <CardHeader>
115
+ <CardTitle>Card spec</CardTitle>
116
+ <CardDescription>
117
+ Edit the description a model would produce, or pick a sample. It is compiled by the
118
+ server's <code>/api/teams/card</code> route.
119
+ </CardDescription>
120
+ </CardHeader>
121
+ <CardContent className="flex flex-col gap-3">
122
+ <div className="flex flex-col gap-1.5">
123
+ <Label htmlFor="teams-sample">Sample</Label>
124
+ <Select value={String(sampleIndex)} onValueChange={chooseSample}>
125
+ <SelectTrigger id="teams-sample" className="w-full">
126
+ <SelectValue />
127
+ </SelectTrigger>
128
+ <SelectContent>
129
+ {CARD_SAMPLES.map((sample, index) => (
130
+ <SelectItem key={sample.label} value={String(index)}>
131
+ {sample.label}
132
+ </SelectItem>
133
+ ))}
134
+ </SelectContent>
135
+ </Select>
136
+ </div>
137
+ <Textarea
138
+ aria-label="Card spec JSON"
139
+ className="min-h-72 font-mono text-xs"
140
+ spellCheck={false}
141
+ value={source}
142
+ onChange={(event) => setSource(event.target.value)}
143
+ />
144
+ <div className="flex items-center gap-2">
145
+ <Button
146
+ size="sm"
147
+ variant="outline"
148
+ onClick={() => setSource(format(CARD_SAMPLES[sampleIndex]!.spec))}
149
+ >
150
+ Reset
151
+ </Button>
152
+ {error ? <span className="text-xs text-destructive">{error}</span> : null}
153
+ </div>
154
+ </CardContent>
155
+ </Card>
156
+
157
+ <Card className="min-h-0">
158
+ <CardHeader>
159
+ <CardTitle>Adaptive Card preview</CardTitle>
160
+ <CardDescription>
161
+ Rendered with the <code>adaptivecards</code> JavaScript renderer, the same one Teams
162
+ preview tools embed.
163
+ </CardDescription>
164
+ </CardHeader>
165
+ <CardContent>
166
+ {result ? (
167
+ <AdaptiveCardView card={result.card} className="ac-container" />
168
+ ) : (
169
+ <p className="text-sm text-muted-foreground">No card yet.</p>
170
+ )}
171
+ </CardContent>
172
+ </Card>
173
+ </div>
174
+ );
175
+ };
@@ -0,0 +1,90 @@
1
+ // React wrapper over the Adaptive Cards JavaScript renderer (the
2
+ // `adaptivecards` npm package). The renderer is imperative - you feed it card
3
+ // JSON and it returns a rendered `HTMLElement` - so this component parses the
4
+ // document and mounts the rendered node into a ref on every change, and wires
5
+ // `Action.OpenUrl` clicks to an optional handler (defaulting to opening the URL
6
+ // in a new tab). This is the same renderer many internal Teams preview tools
7
+ // embed to show cards outside Teams.
8
+ //
9
+ // The renderer deliberately ships NO markdown parser: a `TextBlock` is markdown
10
+ // per the Adaptive Cards spec, but `adaptivecards` leaves the implementation to
11
+ // the host so each host can pick its own (and its own sanitization). Without a
12
+ // host processor Teams' own `**bold**` renders literally, so this module
13
+ // installs `marked` as the processor once at import - see
14
+ // {@link installMarkdownProcessor}.
15
+
16
+ import type { AdaptiveCard as AdaptiveCardDocument } from "@dbx-tools/shared-teams";
17
+ import * as AdaptiveCards from "adaptivecards";
18
+ import { marked } from "marked";
19
+ import { useEffect, useRef } from "react";
20
+
21
+ /**
22
+ * Teach the renderer to process `TextBlock` markdown with `marked`.
23
+ *
24
+ * `onProcessMarkdown` is a STATIC hook on `AdaptiveCard`, so this runs once per
25
+ * module load rather than per component. `didProcess` must be set to `true` or
26
+ * the renderer discards the HTML and falls back to the raw text.
27
+ *
28
+ * `marked` is called in a try/catch and falls back to leaving the text alone:
29
+ * card text can come from a model, and a markdown edge case should degrade to
30
+ * plain text rather than blanking the card.
31
+ */
32
+ const installMarkdownProcessor = () => {
33
+ AdaptiveCards.AdaptiveCard.onProcessMarkdown = (text, result) => {
34
+ try {
35
+ result.outputHtml = marked.parse(text, { async: false, breaks: true }) as string;
36
+ result.didProcess = true;
37
+ } catch {
38
+ result.didProcess = false;
39
+ }
40
+ };
41
+ };
42
+
43
+ installMarkdownProcessor();
44
+
45
+ /** Props for {@link AdaptiveCardView}. */
46
+ export interface AdaptiveCardViewProps {
47
+ /** The compiled Adaptive Card document to render. */
48
+ card: AdaptiveCardDocument;
49
+ /**
50
+ * Called when an `Action.OpenUrl` button is tapped. Defaults to opening the
51
+ * URL in a new tab. Pass a handler to intercept (e.g. to post back instead).
52
+ */
53
+ onOpenUrl?: (url: string) => void;
54
+ /** Extra classes merged onto the mount container. */
55
+ className?: string;
56
+ }
57
+
58
+ /**
59
+ * Render a single Adaptive Card document with the `adaptivecards` renderer.
60
+ * Re-renders whenever the card JSON changes.
61
+ */
62
+ export const AdaptiveCardView = ({ card, onOpenUrl, className }: AdaptiveCardViewProps) => {
63
+ const hostRef = useRef<HTMLDivElement>(null);
64
+
65
+ useEffect(() => {
66
+ const host = hostRef.current;
67
+ if (!host) return;
68
+ const rendered = new AdaptiveCards.AdaptiveCard();
69
+ rendered.onExecuteAction = (action) => {
70
+ if (action instanceof AdaptiveCards.OpenUrlAction && action.url) {
71
+ if (onOpenUrl) onOpenUrl(action.url);
72
+ else window.open(action.url, "_blank", "noopener,noreferrer");
73
+ }
74
+ };
75
+ try {
76
+ rendered.parse(card as unknown as Record<string, unknown>);
77
+ const element = rendered.render();
78
+ host.replaceChildren(...(element ? [element] : []));
79
+ } catch (err) {
80
+ host.replaceChildren(
81
+ Object.assign(document.createElement("pre"), {
82
+ textContent: `Failed to render card: ${(err as Error).message}`,
83
+ }),
84
+ );
85
+ }
86
+ return () => host.replaceChildren();
87
+ }, [card, onOpenUrl]);
88
+
89
+ return <div ref={hostRef} className={className} data-testid="adaptive-card" />;
90
+ };
@@ -0,0 +1,13 @@
1
+ // React surface for `@dbx-tools/ui-teams`: a `TeamsChat` that talks to the
2
+ // conversation endpoint and renders each reply's Adaptive Card attachments like
3
+ // a Teams channel, an `AdaptiveCardView` that renders a compiled Adaptive Card
4
+ // document with the `adaptivecards` JavaScript renderer, and a self-contained
5
+ // `AdaptiveCardGallery` dev tool that edits a `CardSpec`, compiles it through
6
+ // the server's `/api/teams/card` route, and previews the result live. Styled
7
+ // with AppKit tokens.
8
+
9
+ export type { Activity, AdaptiveCard, CardResult, CardSpec } from "@dbx-tools/shared-teams";
10
+ export { TeamsChat, DEFAULT_STARTERS, type TeamsChatProps } from "./teams-chat";
11
+ export { AdaptiveCardView, type AdaptiveCardViewProps } from "./adaptive-card";
12
+ export { AdaptiveCardGallery, type AdaptiveCardGalleryProps } from "./adaptive-card-gallery";
13
+ export { CARD_SAMPLES, type CardSample } from "./samples";
@@ -0,0 +1,57 @@
1
+ // A few ready-made `CardSpec` samples the dev gallery starts from, so the
2
+ // preview shows something interesting before you edit anything. Each exercises
3
+ // a different combination of the spec's optional parts (subtitle, text, facts,
4
+ // actions).
5
+
6
+ import type { CardSpec } from "@dbx-tools/shared-teams";
7
+
8
+ /** A named sample card spec shown in the gallery picker. */
9
+ export interface CardSample {
10
+ /** Label shown in the picker. */
11
+ label: string;
12
+ /** The spec to build/render. */
13
+ spec: CardSpec;
14
+ }
15
+
16
+ /** The built-in sample specs. */
17
+ export const CARD_SAMPLES: CardSample[] = [
18
+ {
19
+ label: "Deployment",
20
+ spec: {
21
+ title: "Deployment succeeded",
22
+ subtitle: "prod • 2 minutes ago",
23
+ text: "The **api** service rolled out cleanly across all regions.",
24
+ facts: [
25
+ { title: "Version", value: "1.4.2" },
26
+ { title: "Owner", value: "alice" },
27
+ { title: "Duration", value: "3m 12s" },
28
+ ],
29
+ actions: [
30
+ { title: "View run", url: "https://example.com/runs/42" },
31
+ { title: "Rollback", url: "https://example.com/runs/42/rollback" },
32
+ ],
33
+ },
34
+ },
35
+ {
36
+ label: "Incident",
37
+ spec: {
38
+ title: "PagerDuty: high latency",
39
+ subtitle: "SEV-2 • checkout-service",
40
+ text: "p95 latency exceeded 800ms for 5 minutes. Auto-scaling triggered.",
41
+ facts: [
42
+ { title: "Status", value: "Investigating" },
43
+ { title: "On-call", value: "bob" },
44
+ { title: "Started", value: "14:02 UTC" },
45
+ ],
46
+ actions: [{ title: "Open incident", url: "https://example.com/incidents/7" }],
47
+ },
48
+ },
49
+ {
50
+ label: "Simple note",
51
+ spec: {
52
+ title: "Weekly report is ready",
53
+ text: "Numbers look healthy this week. See the dashboard for the breakdown.",
54
+ actions: [{ title: "Open dashboard", url: "https://example.com/dashboard" }],
55
+ },
56
+ },
57
+ ];
@@ -0,0 +1,252 @@
1
+ // A Teams-like chat surface for the `@dbx-tools/teams` conversation endpoint.
2
+ // Each turn POSTs a Bot Framework activity to `POST /api/teams/activity` and
3
+ // appends the activities the bot answers with; a reply's Adaptive Card
4
+ // attachments render through the `adaptivecards` renderer, so the transcript
5
+ // looks like a Teams channel where the agent always answers in cards.
6
+
7
+ import { hash, json, string } from "@dbx-tools/shared-core";
8
+ import { activity as activityContract, type Activity } from "@dbx-tools/shared-teams";
9
+ import { Avatar, AvatarFallback, Button, Input, Spinner, cn } from "@dbx-tools/ui-appkit/react";
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { AdaptiveCardView } from "./adaptive-card";
12
+
13
+ /** Props for {@link TeamsChat}. */
14
+ export interface TeamsChatProps {
15
+ /**
16
+ * Conversation endpoint. Defaults to the Bot Framework messaging endpoint
17
+ * (`/api/teams/messages`) - the SAME route a real Teams channel calls, which
18
+ * is why this surface is a faithful preview rather than a parallel
19
+ * implementation.
20
+ *
21
+ * That route only answers in the HTTP response when the server enables its
22
+ * development bypass (`allowUnauthenticated`); against a real bot
23
+ * registration it validates a Bot Service JWT and delivers replies through
24
+ * the Connector API, so this component cannot drive it. Point at
25
+ * `/api/teams/activity` for a server that keeps `/messages` locked down.
26
+ */
27
+ endpoint?: string;
28
+ /** Mastra agent to answer with. Defaults to the server's default agent. */
29
+ agentId?: string;
30
+ /** Display name of the local user, shown on their messages. */
31
+ userName?: string;
32
+ /** Prompts offered as one-tap starters while the transcript is empty. */
33
+ starters?: string[];
34
+ /** Extra classes merged onto the root container. */
35
+ className?: string;
36
+ }
37
+
38
+ /**
39
+ * Default starters. Deliberately generic - they exercise the different parts of
40
+ * a card (facts, bullets, actions) without assuming what the agent is wired to.
41
+ */
42
+ export const DEFAULT_STARTERS = [
43
+ "Summarize today's deployment status",
44
+ "What should I know about this workspace?",
45
+ "Draft a release note for version 1.4.2",
46
+ ];
47
+
48
+ /** The local user's identity on outbound activities. */
49
+ const localUser = (name: string) => ({ id: "local-user", name });
50
+
51
+ /** Whether an activity was authored by the local user rather than the bot. */
52
+ const isFromUser = (item: Activity): boolean => item.from?.id === "local-user";
53
+
54
+ /**
55
+ * A Teams-style conversation with an agent that answers in Adaptive Cards.
56
+ *
57
+ * The transcript is a list of Bot Framework activities - the same objects the
58
+ * endpoint exchanges - so what renders here is exactly what a real channel
59
+ * would receive. A conversation id is minted once per mount and sent on every
60
+ * turn, which is what threads the server's agent memory.
61
+ */
62
+ export const TeamsChat = ({
63
+ endpoint = "/api/teams/messages",
64
+ agentId,
65
+ userName = "You",
66
+ starters = DEFAULT_STARTERS,
67
+ className,
68
+ }: TeamsChatProps) => {
69
+ const [transcript, setTranscript] = useState<Activity[]>([]);
70
+ const [draft, setDraft] = useState("");
71
+ const [pending, setPending] = useState(false);
72
+ const [error, setError] = useState<string | null>(null);
73
+ // One conversation id for the lifetime of the mount: the server maps it onto
74
+ // an agent memory thread, so reusing it is what makes the chat continuous.
75
+ const conversationRef = useRef(`conv-${hash.id()}`);
76
+ const scrollRef = useRef<HTMLDivElement>(null);
77
+ // Tracks the in-flight turn so unmounting (or a rapid second send) cancels it
78
+ // instead of resolving into a dead component.
79
+ const abortRef = useRef<AbortController | null>(null);
80
+
81
+ useEffect(() => () => abortRef.current?.abort(), []);
82
+
83
+ // Keep the newest activity in view as the transcript grows.
84
+ useEffect(() => {
85
+ const node = scrollRef.current;
86
+ if (node) node.scrollTop = node.scrollHeight;
87
+ }, [transcript, pending]);
88
+
89
+ const send = useCallback(
90
+ async (text: string) => {
91
+ const prompt = string.trimToNull(text);
92
+ if (!prompt || pending) return;
93
+
94
+ abortRef.current?.abort();
95
+ const controller = new AbortController();
96
+ abortRef.current = controller;
97
+
98
+ const outbound: Activity = {
99
+ type: "message",
100
+ id: hash.id(),
101
+ timestamp: new Date().toISOString(),
102
+ text: prompt,
103
+ from: localUser(userName),
104
+ conversation: { id: conversationRef.current },
105
+ };
106
+ // Echo the user's turn immediately; the reply is appended when it lands.
107
+ setTranscript((current) => [...current, outbound]);
108
+ setDraft("");
109
+ setError(null);
110
+ setPending(true);
111
+
112
+ try {
113
+ const response = await fetch(endpoint, {
114
+ method: "POST",
115
+ headers: { "content-type": "application/json" },
116
+ body: JSON.stringify({ activity: outbound, ...(agentId ? { agentId } : {}) }),
117
+ signal: controller.signal,
118
+ });
119
+ const body = json.parseRecord(await response.text());
120
+ if (!response.ok) {
121
+ throw new Error(
122
+ string.trimToNull(String(body?.error ?? "")) ?? `Request failed (${response.status})`,
123
+ );
124
+ }
125
+ const parsed = activityContract.activityResponseSchema.safeParse(body);
126
+ if (!parsed.success) throw new Error("The server returned an unexpected reply.");
127
+ setTranscript((current) => [...current, ...parsed.data.activities]);
128
+ } catch (err) {
129
+ // An aborted turn is a deliberate cancel, not a failure to report.
130
+ if ((err as Error)?.name === "AbortError") return;
131
+ setError((err as Error).message);
132
+ } finally {
133
+ setPending(false);
134
+ }
135
+ },
136
+ [agentId, endpoint, pending, userName],
137
+ );
138
+
139
+ return (
140
+ <div className={cn("flex h-full flex-col overflow-hidden rounded-lg border", className)}>
141
+ <div className="flex items-center gap-2 border-b px-4 py-3">
142
+ <span className="text-sm font-semibold">Teams conversation</span>
143
+ <span className="text-muted-foreground text-xs">
144
+ answers arrive as Adaptive Cards from <code>{endpoint}</code>
145
+ </span>
146
+ </div>
147
+
148
+ <div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
149
+ {transcript.length === 0 && (
150
+ <div className="space-y-3 py-6 text-center">
151
+ <p className="text-muted-foreground text-sm">
152
+ Ask the agent something. It replies with an Adaptive Card, the way a Teams bot does.
153
+ </p>
154
+ <div className="flex flex-wrap justify-center gap-2">
155
+ {starters.map((starter) => (
156
+ <Button
157
+ key={starter}
158
+ variant="outline"
159
+ size="sm"
160
+ onClick={() => void send(starter)}
161
+ >
162
+ {starter}
163
+ </Button>
164
+ ))}
165
+ </div>
166
+ </div>
167
+ )}
168
+
169
+ {transcript.map((item, index) => (
170
+ <ActivityBubble key={item.id ?? index} activity={item} userName={userName} />
171
+ ))}
172
+
173
+ {pending && (
174
+ <div className="text-muted-foreground flex items-center gap-2 text-sm">
175
+ <Spinner className="size-4" />
176
+ <span>The agent is composing a card…</span>
177
+ </div>
178
+ )}
179
+
180
+ {error && (
181
+ <p className="text-destructive text-sm" role="alert">
182
+ {error}
183
+ </p>
184
+ )}
185
+ </div>
186
+
187
+ <form
188
+ className="flex items-center gap-2 border-t p-3"
189
+ onSubmit={(event) => {
190
+ event.preventDefault();
191
+ void send(draft);
192
+ }}
193
+ >
194
+ <Input
195
+ value={draft}
196
+ onChange={(event) => setDraft(event.target.value)}
197
+ placeholder="Message the agent…"
198
+ aria-label="Message the agent"
199
+ disabled={pending}
200
+ />
201
+ <Button type="submit" disabled={pending || string.trimToNull(draft) === null}>
202
+ Send
203
+ </Button>
204
+ </form>
205
+ </div>
206
+ );
207
+ };
208
+
209
+ /**
210
+ * One activity in the transcript: the user's text on the right, the bot's reply
211
+ * on the left with each Adaptive Card attachment rendered.
212
+ */
213
+ const ActivityBubble = ({ activity, userName }: { activity: Activity; userName: string }) => {
214
+ const fromUser = isFromUser(activity);
215
+ const cards = activityContract.cardsOf(activity);
216
+ const label = fromUser ? userName : (activity.from?.name ?? "Agent");
217
+
218
+ return (
219
+ <div className={cn("flex gap-3", fromUser && "flex-row-reverse")}>
220
+ <Avatar className="size-8 shrink-0">
221
+ <AvatarFallback className="text-xs">{initials(label)}</AvatarFallback>
222
+ </Avatar>
223
+ <div className={cn("min-w-0 max-w-[85%] space-y-2", fromUser && "items-end text-right")}>
224
+ <span className="text-muted-foreground text-xs">{label}</span>
225
+ {activity.text && (
226
+ <div
227
+ className={cn(
228
+ "rounded-lg px-3 py-2 text-sm",
229
+ fromUser ? "bg-primary text-primary-foreground" : "bg-muted",
230
+ )}
231
+ >
232
+ {activity.text}
233
+ </div>
234
+ )}
235
+ {cards.map((document, index) => (
236
+ <div key={index} className="bg-card rounded-lg border p-3 text-left shadow-sm">
237
+ <AdaptiveCardView card={document} />
238
+ </div>
239
+ ))}
240
+ </div>
241
+ </div>
242
+ );
243
+ };
244
+
245
+ /** Up-to-two-letter initials for an avatar fallback. */
246
+ const initials = (name: string): string =>
247
+ name
248
+ .split(/\s+/)
249
+ .filter(Boolean)
250
+ .slice(0, 2)
251
+ .map((part) => part[0]?.toUpperCase() ?? "")
252
+ .join("") || "?";
package/src/styles.css ADDED
@@ -0,0 +1,92 @@
1
+ /*
2
+ * @dbx-tools/ui-teams stylesheet.
3
+ *
4
+ * Import once from the host app's Tailwind entry, after Tailwind and
5
+ * your AppKit-UI theme:
6
+ *
7
+ * @import "tailwindcss";
8
+ * @import "@databricks/appkit-ui/styles.css";
9
+ * @import "@dbx-tools/ui-teams/styles.css";
10
+ *
11
+ * The gallery UI styles exclusively with AppKit semantic tokens and
12
+ * inherits the host app's theme. The rendered Adaptive Card itself is
13
+ * styled by the `adaptivecards` renderer's own host config; the small
14
+ * rules below only give the mount container sane spacing and let the
15
+ * card's own colors show through on both light and dark themes.
16
+ *
17
+ * Pulls shared styling from `@dbx-tools/ui-appkit` and registers this
18
+ * package's React components with Tailwind via `@source`.
19
+ */
20
+
21
+ @import "@dbx-tools/ui-appkit/styles.css";
22
+
23
+ @source "./react";
24
+
25
+ .ac-container {
26
+ --ac-font-family: inherit;
27
+ }
28
+
29
+ .ac-container .ac-adaptiveCard {
30
+ border-radius: var(--radius, 0.5rem);
31
+ background: var(--card, #fff);
32
+ color: var(--card-foreground, inherit);
33
+ padding: 0.75rem 1rem;
34
+ }
35
+
36
+ /*
37
+ * Restore markdown block styling inside a rendered card.
38
+ *
39
+ * A `TextBlock` is markdown, and the host markdown processor (`marked`, wired
40
+ * up in `adaptive-card.tsx`) emits real `<ul>` / `<ol>` / `<p>` elements. But
41
+ * Tailwind's preflight resets list-style and margins globally, so a bulleted
42
+ * list in a card renders as unmarked, unindented lines. These rules re-apply
43
+ * list markers and paragraph spacing to card-rendered markdown ONLY, scoped
44
+ * under `.ac-textBlock` so nothing outside a card is affected.
45
+ */
46
+ .ac-textBlock ul,
47
+ .ac-textBlock ol {
48
+ margin: 0.25rem 0;
49
+ padding-inline-start: 1.25rem;
50
+ }
51
+
52
+ .ac-textBlock ul {
53
+ list-style: disc outside;
54
+ }
55
+
56
+ .ac-textBlock ol {
57
+ list-style: decimal outside;
58
+ }
59
+
60
+ .ac-textBlock li {
61
+ display: list-item;
62
+ }
63
+
64
+ .ac-textBlock p {
65
+ margin: 0.25rem 0;
66
+ }
67
+
68
+ /*
69
+ * The renderer sets `line-height` INLINE on the `TextBlock` container to the
70
+ * value the card's host config asked for, but the host app's base typography
71
+ * sets a taller `line-height` on `p` itself, which wins over the inherited
72
+ * container value. That double-spaces every card paragraph and makes a wrapped
73
+ * `FactSet` title look like two separate rows. Inheriting puts the renderer's
74
+ * own metrics back in charge.
75
+ */
76
+ .ac-textBlock p,
77
+ .ac-textBlock li {
78
+ line-height: inherit;
79
+ }
80
+
81
+ .ac-textBlock p:first-child {
82
+ margin-top: 0;
83
+ }
84
+
85
+ .ac-textBlock p:last-child {
86
+ margin-bottom: 0;
87
+ }
88
+
89
+ .ac-textBlock a {
90
+ color: var(--primary, #0b6bcb);
91
+ text-decoration: underline;
92
+ }