feat: add synchronized authoring chat dock

This commit is contained in:
lda
2026-07-11 06:44:21 +07:00 Verified
parent 01c52ea9bc
commit 2353ab114e
6 changed files with 228 additions and 45 deletions
@@ -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);
}