feat: add synchronized authoring chat dock
This commit is contained in:
@@ -94,14 +94,16 @@ function ToolGroupRoot({
|
|||||||
|
|
||||||
function ToolGroupTrigger({
|
function ToolGroupTrigger({
|
||||||
count,
|
count,
|
||||||
|
label: providedLabel,
|
||||||
active = false,
|
active = false,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CollapsibleTrigger> & {
|
}: React.ComponentProps<typeof CollapsibleTrigger> & {
|
||||||
count: number;
|
count: number;
|
||||||
|
label?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const label = `${count} tool ${count === 1 ? "call" : "calls"}`;
|
const label = providedLabel ?? `${count} tool ${count === 1 ? "call" : "calls"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { AuthoringConversation } from "./AuthoringConversation.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("AuthoringConversation", () => {
|
||||||
|
it("renders a full prepared chat with real workflow tool calls", () => {
|
||||||
|
render(
|
||||||
|
<AuthoringConversation
|
||||||
|
throughPhase="deployment"
|
||||||
|
activePhase="deployment"
|
||||||
|
surface="stage"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("log", { name: "prepared authoring conversation" }))
|
||||||
|
.toHaveAttribute("data-surface", "stage");
|
||||||
|
expect(screen.getAllByText(/runWorkflowCommand/i).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/deployment/i).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens only the beat-synchronized tool group in dock mode", () => {
|
||||||
|
render(
|
||||||
|
<AuthoringConversation
|
||||||
|
throughPhase="validate"
|
||||||
|
activePhase="validate"
|
||||||
|
surface="dock"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const log = screen.getByRole("log", { name: "prepared authoring conversation" });
|
||||||
|
expect(log).toHaveAttribute("data-surface", "dock");
|
||||||
|
expect(screen.getByRole("button", { name: /validate.*2 tool calls/i }))
|
||||||
|
.toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(screen.getByRole("button", { name: /draft.*3 tool calls/i }))
|
||||||
|
.toHaveAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { AssistantOperatorThread } from "../chat/AssistantOperatorThread.js";
|
||||||
|
import {
|
||||||
|
authoringToolGroupId,
|
||||||
|
projectPreparedAuthoringThread,
|
||||||
|
type AuthoringPhaseId,
|
||||||
|
} from "./authoring-recording.js";
|
||||||
|
|
||||||
|
type AuthoringConversationProps = {
|
||||||
|
readonly throughPhase: AuthoringPhaseId;
|
||||||
|
readonly activePhase: AuthoringPhaseId;
|
||||||
|
readonly surface: "stage" | "dock";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Renders the same prepared conversation at full-stage or compact-dock scale. */
|
||||||
|
export const AuthoringConversation = ({
|
||||||
|
throughPhase,
|
||||||
|
activePhase,
|
||||||
|
surface,
|
||||||
|
}: AuthoringConversationProps) => (
|
||||||
|
<AssistantOperatorThread
|
||||||
|
mode={surface === "stage" ? "full" : "dock"}
|
||||||
|
surface={surface}
|
||||||
|
messages={projectPreparedAuthoringThread(throughPhase)}
|
||||||
|
activeToolGroupId={authoringToolGroupId(activePhase)}
|
||||||
|
ariaLabel="prepared authoring conversation"
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -114,6 +114,32 @@ describe("AssistantOperatorThread", () => {
|
|||||||
expect(run).toHaveBeenCalledOnce();
|
expect(run).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels and opens the synchronized authoring phase group", () => {
|
||||||
|
const messages: ReadonlyArray<AgentMessage> = [
|
||||||
|
{
|
||||||
|
id: "authoring-validate-tools",
|
||||||
|
role: "assistant",
|
||||||
|
parts: [
|
||||||
|
{ type: "tool-call", call: { id: "authoring-validate-command-0", name: "runWorkflowCommand", input: {} } },
|
||||||
|
{ type: "tool-result", result: { callId: "authoring-validate-command-0", name: "runWorkflowCommand", status: "success", output: {} } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AssistantOperatorThread
|
||||||
|
mode="dock"
|
||||||
|
surface="dock"
|
||||||
|
messages={messages}
|
||||||
|
activeToolGroupId="authoring-validate"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("log")).toHaveAttribute("data-surface", "dock");
|
||||||
|
expect(screen.getByRole("button", { name: /validate.*1 tool call/i }))
|
||||||
|
.toHaveAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders structured tool results through the generated fallback result slot", () => {
|
it("renders structured tool results through the generated fallback result slot", () => {
|
||||||
const messages: ReadonlyArray<AgentMessage> = [
|
const messages: ReadonlyArray<AgentMessage> = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import type { ToolCallMessagePartStatus } from "@assistant-ui/react";
|
import type { ToolCallMessagePartStatus } from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
ToolFallbackArgs,
|
ToolFallbackArgs,
|
||||||
@@ -27,6 +27,8 @@ type AssistantOperatorThreadProps = {
|
|||||||
readonly submitApproval?: (() => void) | undefined;
|
readonly submitApproval?: (() => void) | undefined;
|
||||||
readonly cancelApproval?: (() => void) | undefined;
|
readonly cancelApproval?: (() => void) | undefined;
|
||||||
readonly ariaLabel?: string | undefined;
|
readonly ariaLabel?: string | undefined;
|
||||||
|
readonly surface?: "stage" | "dock" | undefined;
|
||||||
|
readonly activeToolGroupId?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatJson = (value: unknown): string => {
|
const formatJson = (value: unknown): string => {
|
||||||
@@ -87,16 +89,18 @@ const renderContentPart = (
|
|||||||
submitApproval?: (() => void) | undefined,
|
submitApproval?: (() => void) | undefined,
|
||||||
cancelApproval?: (() => void) | undefined,
|
cancelApproval?: (() => void) | undefined,
|
||||||
defaultOpen?: boolean,
|
defaultOpen?: boolean,
|
||||||
|
pairedResult?: Extract<ToolRenderPart, { readonly type: "tool-result" }>,
|
||||||
): ReactNode => {
|
): ReactNode => {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
return <p style={{ whiteSpace: "pre-line" }}>{part.text}</p>;
|
return <p style={{ whiteSpace: "pre-line" }}>{part.text}</p>;
|
||||||
}
|
}
|
||||||
const status = statusForToolPart(part);
|
const resultPart = pairedResult ?? (part.type === "tool-result" ? part : undefined);
|
||||||
|
const status = resultPart ? statusForToolPart(resultPart) : undefined;
|
||||||
const toolName = part.toolName;
|
const toolName = part.toolName;
|
||||||
const args = part.type === "tool-call" ? part.args : undefined;
|
const args = part.type === "tool-call" ? part.args : undefined;
|
||||||
const contract = part.type === "tool-call" ? approvalContractFromArgs(args) : undefined;
|
const contract = part.type === "tool-call" ? approvalContractFromArgs(args) : undefined;
|
||||||
const argsText = args !== undefined ? formatJson(args) : undefined;
|
const argsText = args !== undefined ? formatJson(args) : undefined;
|
||||||
const result = resultForToolPart(part);
|
const result = resultPart ? resultForToolPart(resultPart) : undefined;
|
||||||
|
|
||||||
if (toolName === "resumeIssueReview" && contract) {
|
if (toolName === "resumeIssueReview" && contract) {
|
||||||
return (
|
return (
|
||||||
@@ -132,11 +136,17 @@ const renderContentPart = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AssistantMessageBody = ({
|
const AssistantMessageBody = ({
|
||||||
|
messageId,
|
||||||
parts,
|
parts,
|
||||||
|
openToolGroups,
|
||||||
|
setToolGroupOpen,
|
||||||
submitApproval,
|
submitApproval,
|
||||||
cancelApproval,
|
cancelApproval,
|
||||||
}: {
|
}: {
|
||||||
|
readonly messageId: string;
|
||||||
readonly parts: readonly AssistantContentPart[];
|
readonly parts: readonly AssistantContentPart[];
|
||||||
|
readonly openToolGroups: ReadonlySet<string>;
|
||||||
|
readonly setToolGroupOpen: (groupId: string, open: boolean) => void;
|
||||||
readonly submitApproval?: (() => void) | undefined;
|
readonly submitApproval?: (() => void) | undefined;
|
||||||
readonly cancelApproval?: (() => void) | undefined;
|
readonly cancelApproval?: (() => void) | undefined;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -160,24 +170,49 @@ const AssistantMessageBody = ({
|
|||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolRun.length === 1) {
|
const calls = toolRun.filter((tool): tool is Extract<ToolRenderPart, { readonly type: "tool-call" }> =>
|
||||||
|
tool.type === "tool-call");
|
||||||
|
const logicalTools = calls.length > 0 ? calls : toolRun;
|
||||||
|
|
||||||
|
if (logicalTools.length === 1 && !messageId.startsWith("authoring-")) {
|
||||||
rendered.push(
|
rendered.push(
|
||||||
<div key={`tool-${toolRunStart}`}>
|
<div key={`tool-${toolRunStart}`}>
|
||||||
{renderContentPart(toolRun[0]!, submitApproval, cancelApproval)}
|
{renderContentPart(logicalTools[0]!, submitApproval, cancelApproval)}
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupId = messageId.endsWith("-tools") ? messageId.slice(0, -"-tools".length) : messageId;
|
||||||
|
const phase = groupId.startsWith("authoring-") ? groupId.slice("authoring-".length) : null;
|
||||||
|
const phaseLabel = phase ? `${phase[0]!.toUpperCase()}${phase.slice(1)}` : null;
|
||||||
|
const count = logicalTools.length;
|
||||||
rendered.push(
|
rendered.push(
|
||||||
<ToolGroupRoot key={`tool-group-${toolRunStart}`} defaultOpen>
|
<ToolGroupRoot
|
||||||
<ToolGroupTrigger count={toolRun.length} />
|
key={`tool-group-${toolRunStart}`}
|
||||||
|
{...(phaseLabel
|
||||||
|
? {
|
||||||
|
open: openToolGroups.has(groupId),
|
||||||
|
onOpenChange: (open: boolean) => setToolGroupOpen(groupId, open),
|
||||||
|
}
|
||||||
|
: { defaultOpen: true })}
|
||||||
|
>
|
||||||
|
<ToolGroupTrigger
|
||||||
|
count={count}
|
||||||
|
{...(phaseLabel ? { label: `${phaseLabel} · ${count} tool ${count === 1 ? "call" : "calls"}` } : {})}
|
||||||
|
/>
|
||||||
<ToolGroupContent>
|
<ToolGroupContent>
|
||||||
{toolRun.map((tool, toolIndex) => (
|
{logicalTools.map((tool, toolIndex) => {
|
||||||
|
const pairedResult = tool.type === "tool-call"
|
||||||
|
? toolRun.find((candidate): candidate is Extract<ToolRenderPart, { readonly type: "tool-result" }> =>
|
||||||
|
candidate.type === "tool-result" && candidate.toolCallId === tool.toolCallId)
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
<div key={`${tool.type}-${tool.toolName}-${tool.toolCallId ?? "no-id"}-${toolIndex}`}>
|
<div key={`${tool.type}-${tool.toolName}-${tool.toolCallId ?? "no-id"}-${toolIndex}`}>
|
||||||
{renderContentPart(tool, submitApproval, cancelApproval, false)}
|
{renderContentPart(tool, submitApproval, cancelApproval, false, pairedResult)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ToolGroupContent>
|
</ToolGroupContent>
|
||||||
</ToolGroupRoot>,
|
</ToolGroupRoot>,
|
||||||
);
|
);
|
||||||
@@ -213,8 +248,26 @@ export const AssistantOperatorThread = ({
|
|||||||
submitApproval,
|
submitApproval,
|
||||||
cancelApproval,
|
cancelApproval,
|
||||||
ariaLabel = "operator conversation",
|
ariaLabel = "operator conversation",
|
||||||
|
surface,
|
||||||
|
activeToolGroupId,
|
||||||
}: AssistantOperatorThreadProps) => {
|
}: AssistantOperatorThreadProps) => {
|
||||||
const projected = useMemo(() => projectAgentMessagesForAssistant(messages), [messages]);
|
const projected = useMemo(() => projectAgentMessagesForAssistant(messages), [messages]);
|
||||||
|
const [openToolGroups, setOpenToolGroups] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set(activeToolGroupId ? [activeToolGroupId] : []),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeToolGroupId) setOpenToolGroups(new Set([activeToolGroupId]));
|
||||||
|
}, [activeToolGroupId]);
|
||||||
|
|
||||||
|
const setToolGroupOpen = useCallback((groupId: string, open: boolean) => {
|
||||||
|
setOpenToolGroups((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (open) next.add(groupId);
|
||||||
|
else next.delete(groupId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRun = useCallback(() => {
|
const handleRun = useCallback(() => {
|
||||||
if (!runAction || runAction.disabled) return;
|
if (!runAction || runAction.disabled) return;
|
||||||
@@ -222,7 +275,13 @@ export const AssistantOperatorThread = ({
|
|||||||
}, [runAction]);
|
}, [runAction]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="assistant-operator-thread" data-mode={mode} role="log" aria-label={ariaLabel}>
|
<section
|
||||||
|
className="assistant-operator-thread"
|
||||||
|
data-mode={mode}
|
||||||
|
data-surface={surface ?? mode}
|
||||||
|
role="log"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
<div className="assistant-thread">
|
<div className="assistant-thread">
|
||||||
<div className="assistant-thread__viewport">
|
<div className="assistant-thread__viewport">
|
||||||
{projected.map((message) => {
|
{projected.map((message) => {
|
||||||
@@ -239,7 +298,10 @@ export const AssistantOperatorThread = ({
|
|||||||
return (
|
return (
|
||||||
<MessageBubble key={message.id} role="assistant">
|
<MessageBubble key={message.id} role="assistant">
|
||||||
<AssistantMessageBody
|
<AssistantMessageBody
|
||||||
|
messageId={message.id}
|
||||||
parts={message.content}
|
parts={message.content}
|
||||||
|
openToolGroups={openToolGroups}
|
||||||
|
setToolGroupOpen={setToolGroupOpen}
|
||||||
submitApproval={submitApproval}
|
submitApproval={submitApproval}
|
||||||
cancelApproval={cancelApproval}
|
cancelApproval={cancelApproval}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2010,6 +2010,37 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assistant-operator-thread[data-surface="stage"] {
|
||||||
|
width: min(100%, 70rem);
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-operator-thread[data-surface="dock"] {
|
||||||
|
height: 100%;
|
||||||
|
border-top: 1px solid var(--stage-line);
|
||||||
|
transition: height 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-operator-thread[data-surface="dock"] .assistant-thread {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: color-mix(in oklch, var(--stage-inset) 94%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-operator-thread[data-surface="dock"] .assistant-thread__viewport {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-operator-thread[data-surface="dock"] .assistant-message {
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.assistant-operator-thread[data-surface="dock"] {
|
||||||
|
transition-duration: 0.01ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.assistant-operator-thread__action button {
|
.assistant-operator-thread__action button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--accent-cyan);
|
border: 1px solid var(--accent-cyan);
|
||||||
@@ -2795,44 +2826,40 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prepared-lifecycle-scene__commands {
|
.prepared-lifecycle-scene__proof {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||||
gap: 0.3rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prepared-lifecycle-scene__command {
|
.prepared-lifecycle-scene__proof code {
|
||||||
display: flex;
|
min-width: 0;
|
||||||
align-items: center;
|
padding: 0.7rem;
|
||||||
justify-content: space-between;
|
border: 1px solid color-mix(in oklch, var(--accent-cyan) 35%, var(--stage-line));
|
||||||
padding: 0.3rem 0.45rem;
|
border-radius: 0.35rem;
|
||||||
background: color-mix(in oklch, var(--stage-inset) 65%, transparent);
|
background: color-mix(in oklch, var(--stage-inset) 76%, black);
|
||||||
border-radius: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prepared-lifecycle-scene__command code {
|
|
||||||
font-family: var(--font-evidence);
|
|
||||||
font-size: 0.65rem;
|
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
font: 600 0.72rem/1.3 var(--font-evidence);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
.prepared-lifecycle-scene__command-result {
|
|
||||||
font-size: 0.6rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prepared-lifecycle-scene__command-result[data-result="success"] {
|
|
||||||
color: var(--accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prepared-lifecycle-scene__command-result[data-result="diagnostic"] {
|
|
||||||
color: var(--accent-yellow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prepared-lifecycle-scene__receipt {
|
.prepared-lifecycle-scene__receipt {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.35rem 0;
|
gap: 0.5rem;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
border: 1px solid color-mix(in oklch, var(--stage-line) 80%, transparent);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: color-mix(in oklch, var(--stage-inset) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepared-lifecycle-scene__receipt strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: 700 0.72rem/1 var(--font-interface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepared-lifecycle-scene__receipt > span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font: 0.66rem/1 var(--font-evidence);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user