feat: add synchronized authoring chat dock
This commit is contained in:
@@ -94,14 +94,16 @@ function ToolGroupRoot({
|
||||
|
||||
function ToolGroupTrigger({
|
||||
count,
|
||||
label: providedLabel,
|
||||
active = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
label?: string;
|
||||
active?: boolean;
|
||||
}) {
|
||||
const label = `${count} tool ${count === 1 ? "call" : "calls"}`;
|
||||
const label = providedLabel ?? `${count} tool ${count === 1 ? "call" : "calls"}`;
|
||||
|
||||
return (
|
||||
<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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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 {
|
||||
ToolFallbackArgs,
|
||||
@@ -27,6 +27,8 @@ type AssistantOperatorThreadProps = {
|
||||
readonly submitApproval?: (() => void) | undefined;
|
||||
readonly cancelApproval?: (() => void) | undefined;
|
||||
readonly ariaLabel?: string | undefined;
|
||||
readonly surface?: "stage" | "dock" | undefined;
|
||||
readonly activeToolGroupId?: string | undefined;
|
||||
};
|
||||
|
||||
const formatJson = (value: unknown): string => {
|
||||
@@ -87,16 +89,18 @@ const renderContentPart = (
|
||||
submitApproval?: (() => void) | undefined,
|
||||
cancelApproval?: (() => void) | undefined,
|
||||
defaultOpen?: boolean,
|
||||
pairedResult?: Extract<ToolRenderPart, { readonly type: "tool-result" }>,
|
||||
): ReactNode => {
|
||||
if (part.type === "text") {
|
||||
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 args = part.type === "tool-call" ? part.args : undefined;
|
||||
const contract = part.type === "tool-call" ? approvalContractFromArgs(args) : undefined;
|
||||
const argsText = args !== undefined ? formatJson(args) : undefined;
|
||||
const result = resultForToolPart(part);
|
||||
const result = resultPart ? resultForToolPart(resultPart) : undefined;
|
||||
|
||||
if (toolName === "resumeIssueReview" && contract) {
|
||||
return (
|
||||
@@ -132,11 +136,17 @@ const renderContentPart = (
|
||||
};
|
||||
|
||||
const AssistantMessageBody = ({
|
||||
messageId,
|
||||
parts,
|
||||
openToolGroups,
|
||||
setToolGroupOpen,
|
||||
submitApproval,
|
||||
cancelApproval,
|
||||
}: {
|
||||
readonly messageId: string;
|
||||
readonly parts: readonly AssistantContentPart[];
|
||||
readonly openToolGroups: ReadonlySet<string>;
|
||||
readonly setToolGroupOpen: (groupId: string, open: boolean) => void;
|
||||
readonly submitApproval?: (() => void) | undefined;
|
||||
readonly cancelApproval?: (() => void) | undefined;
|
||||
}) => {
|
||||
@@ -160,24 +170,49 @@ const AssistantMessageBody = ({
|
||||
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(
|
||||
<div key={`tool-${toolRunStart}`}>
|
||||
{renderContentPart(toolRun[0]!, submitApproval, cancelApproval)}
|
||||
{renderContentPart(logicalTools[0]!, submitApproval, cancelApproval)}
|
||||
</div>,
|
||||
);
|
||||
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(
|
||||
<ToolGroupRoot key={`tool-group-${toolRunStart}`} defaultOpen>
|
||||
<ToolGroupTrigger count={toolRun.length} />
|
||||
<ToolGroupRoot
|
||||
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>
|
||||
{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}`}>
|
||||
{renderContentPart(tool, submitApproval, cancelApproval, false)}
|
||||
{renderContentPart(tool, submitApproval, cancelApproval, false, pairedResult)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ToolGroupContent>
|
||||
</ToolGroupRoot>,
|
||||
);
|
||||
@@ -213,8 +248,26 @@ export const AssistantOperatorThread = ({
|
||||
submitApproval,
|
||||
cancelApproval,
|
||||
ariaLabel = "operator conversation",
|
||||
surface,
|
||||
activeToolGroupId,
|
||||
}: AssistantOperatorThreadProps) => {
|
||||
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(() => {
|
||||
if (!runAction || runAction.disabled) return;
|
||||
@@ -222,7 +275,13 @@ export const AssistantOperatorThread = ({
|
||||
}, [runAction]);
|
||||
|
||||
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__viewport">
|
||||
{projected.map((message) => {
|
||||
@@ -239,7 +298,10 @@ export const AssistantOperatorThread = ({
|
||||
return (
|
||||
<MessageBubble key={message.id} role="assistant">
|
||||
<AssistantMessageBody
|
||||
messageId={message.id}
|
||||
parts={message.content}
|
||||
openToolGroups={openToolGroups}
|
||||
setToolGroupOpen={setToolGroupOpen}
|
||||
submitApproval={submitApproval}
|
||||
cancelApproval={cancelApproval}
|
||||
/>
|
||||
|
||||
@@ -2010,6 +2010,37 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
@@ -2795,44 +2826,40 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.prepared-lifecycle-scene__commands {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
.prepared-lifecycle-scene__proof {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.prepared-lifecycle-scene__command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0.45rem;
|
||||
background: color-mix(in oklch, var(--stage-inset) 65%, transparent);
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.prepared-lifecycle-scene__command code {
|
||||
font-family: var(--font-evidence);
|
||||
font-size: 0.65rem;
|
||||
.prepared-lifecycle-scene__proof code {
|
||||
min-width: 0;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid color-mix(in oklch, var(--accent-cyan) 35%, var(--stage-line));
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in oklch, var(--stage-inset) 76%, black);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.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);
|
||||
font: 600 0.72rem/1.3 var(--font-evidence);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.prepared-lifecycle-scene__receipt {
|
||||
display: flex;
|
||||
align-items: 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