feat: add presentation chat primitives

This commit is contained in:
lda
2026-07-09 16:39:42 +07:00 Verified
parent 3b93dfcaa5
commit 63d0c8f912
3 changed files with 289 additions and 0 deletions
@@ -0,0 +1,71 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
Conversation,
ConversationContent,
Message,
MessageContent,
MessageResponse,
PromptAction,
Tool,
ToolInput,
ToolOutput,
} from "./ChatPrimitives.js";
describe("ChatPrimitives", () => {
it("renders conversation and message landmarks", () => {
render(
<Conversation mode="dock">
<ConversationContent>
<Message from="assistant">
<MessageContent>
<MessageResponse>Live target is ready.</MessageResponse>
</MessageContent>
</Message>
</ConversationContent>
</Conversation>,
);
expect(screen.getByRole("log", { name: "operator conversation" })).toHaveAttribute("data-mode", "dock");
expect(screen.getByText("Live target is ready.")).toBeInTheDocument();
});
it("keeps tool details collapsed by default and expands on click", async () => {
const user = userEvent.setup();
render(
<Tool label="Workflow operation" name="workflow.runs.start" state="success">
<ToolInput input={{ deployment_id: "demo.default" }} />
<ToolOutput status="success" output={{ run_id: "run_123" }} />
</Tool>,
);
const toggle = screen.getByRole("button", { name: /workflow operation/i });
expect(screen.queryByText(/deployment_id/)).not.toBeInTheDocument();
await user.click(toggle);
expect(screen.getByText(/deployment_id/)).toBeInTheDocument();
expect(screen.getByText(/run_123/)).toBeInTheDocument();
});
it("supports default-open tools for currently relevant operations", () => {
render(
<Tool label="Approval required" name="resumeIssueReview" state="pending" defaultOpen>
<ToolOutput status="pending" output="waiting for operator" />
</Tool>,
);
expect(screen.getByText("waiting for operator")).toBeInTheDocument();
});
it("renders prompt action buttons", async () => {
const user = userEvent.setup();
const run = vi.fn();
render(<PromptAction label="Run prepared workflow" onClick={run} disabled={false} />);
await user.click(screen.getByRole("button", { name: "Run prepared workflow" }));
expect(run).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,100 @@
import { useId, useState, type ReactNode } from "react";
export type ConversationMode = "hidden" | "rail" | "dock";
export type MessageFrom = "user" | "assistant" | "system";
export type ToolState = "pending" | "success" | "error";
type ChildrenProps = {
readonly children: ReactNode;
};
const formatJson = (value: unknown): string => {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
export const Conversation = ({ mode, children }: ChildrenProps & { readonly mode: ConversationMode }) => (
<section className="ai-chat-conversation" data-mode={mode} role="log" aria-label="operator conversation">
{children}
</section>
);
export const ConversationContent = ({ children }: ChildrenProps) => (
<div className="ai-chat-conversation__content">{children}</div>
);
export const Message = ({ from, children }: ChildrenProps & { readonly from: MessageFrom }) => (
<article className="ai-chat-message" data-from={from}>
<div className="ai-chat-message__avatar" aria-hidden="true">{from === "user" ? "U" : "\u03BB"}</div>
<div className="ai-chat-message__body">{children}</div>
</article>
);
export const MessageContent = ({ children }: ChildrenProps) => (
<div className="ai-chat-message__content">{children}</div>
);
export const MessageResponse = ({ children }: ChildrenProps) => (
<div className="ai-chat-message__response">{children}</div>
);
export const Tool = ({
label,
name,
state,
defaultOpen = false,
children,
}: ChildrenProps & {
readonly label: string;
readonly name: string;
readonly state: ToolState;
readonly defaultOpen?: boolean;
}) => {
const [open, setOpen] = useState(defaultOpen);
const contentId = useId();
return (
<div className="ai-chat-tool" data-state={state} data-open={open ? "true" : "false"}>
<button
type="button"
className="ai-chat-tool__header"
aria-expanded={open}
aria-controls={contentId}
onClick={() => setOpen((current) => !current)}
>
<span className="ai-chat-tool__label">{label}</span>
<code>{name}</code>
<small>{state}</small>
</button>
{open ? <div id={contentId} className="ai-chat-tool__content">{children}</div> : null}
</div>
);
};
export const ToolInput = ({ input }: { readonly input: unknown }) => (
<pre className="ai-chat-tool__io" aria-label="tool input">{formatJson(input)}</pre>
);
export const ToolOutput = ({ status, output }: { readonly status: ToolState; readonly output: unknown }) => (
<div className="ai-chat-tool__output" data-state={status}>
<span>{status}</span>
<pre className="ai-chat-tool__io" aria-label="tool output">{formatJson(output)}</pre>
</div>
);
export const PromptAction = ({
label,
disabled,
onClick,
}: {
readonly label: string;
readonly disabled: boolean;
readonly onClick: () => void;
}) => (
<div className="ai-chat-prompt-action">
<button type="button" onClick={onClick} disabled={disabled}>{label}</button>
</div>
);
@@ -1358,3 +1358,121 @@
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
} }
/* Source-owned AI Elements-style primitives. These classes intentionally mirror
conversation/message/tool concepts so a future AI SDK driver can reuse the
same surface without changing the demo timeline state model. */
.operator-chat .ai-chat-conversation {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
color: var(--text-primary);
}
.operator-chat .ai-chat-conversation__content {
display: grid;
gap: 0.75rem;
overflow: auto;
scrollbar-width: thin;
}
.operator-chat .ai-chat-message {
display: grid;
grid-template-columns: 1.8rem minmax(0, 1fr);
gap: 0.65rem;
align-items: start;
}
.operator-chat .ai-chat-message__avatar {
display: grid;
width: 1.8rem;
height: 1.8rem;
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent-cyan), transparent 55%);
border-radius: 999px;
color: var(--accent-cyan);
font: 700 0.72rem/1 var(--font-mono);
}
.operator-chat .ai-chat-message__body {
min-width: 0;
}
.operator-chat .ai-chat-message__content {
display: grid;
gap: 0.55rem;
}
.operator-chat .ai-chat-message__response {
padding: 0.72rem 0.82rem;
border: 1px solid color-mix(in srgb, var(--stage-line), transparent 18%);
border-radius: 1rem;
background: color-mix(in srgb, var(--stage-surface), transparent 12%);
color: var(--text-primary);
}
.operator-chat .ai-chat-tool {
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--stage-line), transparent 20%);
border-radius: 0.85rem;
background: color-mix(in srgb, var(--stage-inset), transparent 10%);
}
.operator-chat .ai-chat-tool__header {
display: grid;
width: 100%;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.55rem;
align-items: center;
padding: 0.6rem 0.7rem;
border: 0;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
}
.operator-chat .ai-chat-tool__header code {
overflow: hidden;
color: var(--text-muted);
font: 500 0.72rem/1.2 var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.operator-chat .ai-chat-tool__label {
color: var(--text-primary);
font-weight: 700;
}
.operator-chat .ai-chat-tool__header small {
color: var(--accent-cyan);
font: 700 0.66rem/1 var(--font-mono);
}
.operator-chat .ai-chat-tool__content {
display: grid;
gap: 0.55rem;
padding: 0 0.7rem 0.7rem;
}
.operator-chat .ai-chat-tool__io {
max-height: 8rem;
margin: 0;
overflow: auto;
border-radius: 0.7rem;
background: color-mix(in srgb, black, transparent 18%);
color: color-mix(in srgb, var(--text-primary), white 8%);
font: 0.68rem/1.45 var(--font-mono);
}
.operator-chat .ai-chat-prompt-action button {
width: 100%;
padding: 0.72rem 0.9rem;
border: 1px solid color-mix(in srgb, var(--accent-cyan), transparent 30%);
border-radius: 999px;
background: color-mix(in srgb, var(--accent-cyan), transparent 84%);
color: var(--text-primary);
font-weight: 800;
}