refactor: remove hand rolled presentation chat primitives
This commit is contained in:
@@ -79,7 +79,7 @@ describe("SceneBody", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByLabelText("chat tool loop versus reusable automation")).toBeInTheDocument();
|
expect(screen.getByLabelText("chat tool loop versus reusable automation")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("list", { name: /one-off chat and tool transcript/i })).toBeInTheDocument();
|
expect(screen.getByRole("log", { name: /one-off assistant transcript/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("group", { name: /durable workflow blueprint/i })).toBeInTheDocument();
|
expect(screen.getByRole("group", { name: /durable workflow blueprint/i })).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Draft")).not.toBeInTheDocument();
|
expect(screen.queryByText("Draft")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Artifact")).not.toBeInTheDocument();
|
expect(screen.queryByText("Artifact")).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { useId, useState, type ReactNode } from "react";
|
|
||||||
|
|
||||||
export type ConversationMode = "hidden" | "full" | "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>
|
|
||||||
);
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { AgentMessage } from "../../demo/agent/events.js";
|
|
||||||
import { projectAgentMessage } from "./agentChatProjection.js";
|
|
||||||
|
|
||||||
describe("agentChatProjection", () => {
|
|
||||||
it("projects text parts", () => {
|
|
||||||
const message: AgentMessage = {
|
|
||||||
id: "m1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "text", text: "Live target is ready." }],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(projectAgentMessage(message)).toMatchObject({
|
|
||||||
id: "m1",
|
|
||||||
from: "assistant",
|
|
||||||
parts: [{ kind: "text", text: "Live target is ready." }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("projects workflow start as an expanded workflow operation", () => {
|
|
||||||
const message: AgentMessage = {
|
|
||||||
id: "m1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "tool-call", call: { id: "call-1", name: "startPreparedReportRun", input: { mode: "live" } } }],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(projectAgentMessage(message).parts[0]).toMatchObject({
|
|
||||||
kind: "tool",
|
|
||||||
label: "Workflow operation",
|
|
||||||
name: "startPreparedReportRun",
|
|
||||||
state: "pending",
|
|
||||||
defaultOpen: true,
|
|
||||||
input: { mode: "live" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("projects ordinary tool results as collapsed tool records", () => {
|
|
||||||
const message: AgentMessage = {
|
|
||||||
id: "m1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "tool-result", result: { callId: "call-1", name: "readRunTrace", status: "success", output: { frames: 4 } } }],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(projectAgentMessage(message).parts[0]).toMatchObject({
|
|
||||||
kind: "tool",
|
|
||||||
label: "Tool result",
|
|
||||||
name: "readRunTrace",
|
|
||||||
state: "success",
|
|
||||||
defaultOpen: false,
|
|
||||||
output: { frames: 4 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("projects approval requests with their contract", () => {
|
|
||||||
const message: AgentMessage = {
|
|
||||||
id: "m1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{
|
|
||||||
type: "approval-request",
|
|
||||||
callId: "call-1",
|
|
||||||
name: "resumeIssueReview",
|
|
||||||
prompt: "Submit resume request?",
|
|
||||||
contract: {
|
|
||||||
kind: "issue_review",
|
|
||||||
outcomes: ["submitted", "cancelled"],
|
|
||||||
resumeSchema: { type: "object" },
|
|
||||||
resumePayloadPreview: { selected_issue_ids: ["risk-1"] },
|
|
||||||
runId: "run_1",
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(projectAgentMessage(message).parts[0]).toMatchObject({
|
|
||||||
kind: "approval",
|
|
||||||
name: "resumeIssueReview",
|
|
||||||
prompt: "Submit resume request?",
|
|
||||||
contract: { kind: "issue_review", runId: "run_1" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import type { AgentApprovalContract, AgentMessage, AgentMessagePart } from "../../demo/agent/events.js";
|
|
||||||
import type { MessageFrom, ToolState } from "./ChatPrimitives.js";
|
|
||||||
|
|
||||||
export type ProjectedChatPart =
|
|
||||||
| { readonly kind: "text"; readonly text: string }
|
|
||||||
| {
|
|
||||||
readonly kind: "tool";
|
|
||||||
readonly label: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly state: ToolState;
|
|
||||||
readonly defaultOpen: boolean;
|
|
||||||
readonly input?: unknown;
|
|
||||||
readonly output?: unknown;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly kind: "approval";
|
|
||||||
readonly name: string;
|
|
||||||
readonly prompt: string;
|
|
||||||
readonly contract?: AgentApprovalContract | undefined;
|
|
||||||
}
|
|
||||||
| { readonly kind: "error"; readonly message: string };
|
|
||||||
|
|
||||||
export type ProjectedChatMessage = {
|
|
||||||
readonly id: string;
|
|
||||||
readonly from: MessageFrom;
|
|
||||||
readonly parts: ReadonlyArray<ProjectedChatPart>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toolStateFromResult = (status: AgentMessagePart & { readonly type: "tool-result" }): ToolState =>
|
|
||||||
status.result.status === "failure" ? "error" : "success";
|
|
||||||
|
|
||||||
const WORKFLOW_START_TOOL = "startPreparedReportRun";
|
|
||||||
|
|
||||||
const projectPart = (part: AgentMessagePart): ProjectedChatPart | null => {
|
|
||||||
switch (part.type) {
|
|
||||||
case "text":
|
|
||||||
return { kind: "text", text: part.text };
|
|
||||||
case "tool-call":
|
|
||||||
return {
|
|
||||||
kind: "tool",
|
|
||||||
label: part.call.name === WORKFLOW_START_TOOL ? "Workflow operation" : "Tool call",
|
|
||||||
name: part.call.name,
|
|
||||||
state: "pending",
|
|
||||||
defaultOpen: part.call.name === WORKFLOW_START_TOOL,
|
|
||||||
input: part.call.input,
|
|
||||||
};
|
|
||||||
case "tool-result":
|
|
||||||
return {
|
|
||||||
kind: "tool",
|
|
||||||
label: "Tool result",
|
|
||||||
name: part.result.name,
|
|
||||||
state: toolStateFromResult(part),
|
|
||||||
defaultOpen: false,
|
|
||||||
output: part.result.output,
|
|
||||||
};
|
|
||||||
case "presentation-action":
|
|
||||||
return {
|
|
||||||
kind: "tool",
|
|
||||||
label: "Presentation action",
|
|
||||||
name: part.action.type,
|
|
||||||
state: "success",
|
|
||||||
defaultOpen: false,
|
|
||||||
output: part.action,
|
|
||||||
};
|
|
||||||
case "approval-request":
|
|
||||||
return {
|
|
||||||
kind: "approval",
|
|
||||||
name: part.name,
|
|
||||||
prompt: part.prompt,
|
|
||||||
contract: part.contract,
|
|
||||||
};
|
|
||||||
case "error":
|
|
||||||
return { kind: "error", message: part.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const projectAgentMessage = (message: AgentMessage): ProjectedChatMessage => ({
|
|
||||||
id: message.id,
|
|
||||||
from: message.role === "user" ? "user" : "assistant",
|
|
||||||
parts: message.parts.flatMap((part) => {
|
|
||||||
const projected = projectPart(part);
|
|
||||||
return projected ? [projected] : [];
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -1736,121 +1736,6 @@
|
|||||||
padding: 0.65rem;
|
padding: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Run facts panels */
|
/* Run facts panels */
|
||||||
.run-facts-card {
|
.run-facts-card {
|
||||||
padding: 1.2rem 1.4rem;
|
padding: 1.2rem 1.4rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user