feat: compose continuous authoring workspace

This commit is contained in:
lda
2026-07-11 07:04:00 +07:00 Verified
parent b5f8ae0c5e
commit c76be621d9
13 changed files with 155 additions and 497 deletions
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentMessage } from "../../demo/agent/events.js";
@@ -140,6 +140,36 @@ describe("AssistantOperatorThread", () => {
.toHaveAttribute("aria-expanded", "true");
});
it("scrolls the active authoring group into the dock viewport", async () => {
const setScrollTop = vi.fn();
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", {
configurable: true,
get: () => 0,
set: setScrollTop,
});
const messages: ReadonlyArray<AgentMessage> = [
{
id: "authoring-draft-tools",
role: "assistant",
parts: [
{ type: "tool-call", call: { id: "authoring-draft-command-0", name: "runWorkflowCommand", input: {} } },
{ type: "tool-result", result: { callId: "authoring-draft-command-0", name: "runWorkflowCommand", status: "success", output: {} } },
],
},
];
render(
<AssistantOperatorThread
mode="dock"
surface="dock"
messages={messages}
activeToolGroupId="authoring-draft"
/>,
);
await waitFor(() => expect(setScrollTop).toHaveBeenCalledWith(0));
});
it("renders structured tool results through the generated fallback result slot", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { ToolCallMessagePartStatus } from "@assistant-ui/react";
import {
ToolFallbackArgs,
@@ -190,6 +190,7 @@ const AssistantMessageBody = ({
rendered.push(
<ToolGroupRoot
key={`tool-group-${toolRunStart}`}
data-tool-group-id={groupId}
{...(phaseLabel
? {
open: openToolGroups.has(groupId),
@@ -252,6 +253,7 @@ export const AssistantOperatorThread = ({
activeToolGroupId,
}: AssistantOperatorThreadProps) => {
const projected = useMemo(() => projectAgentMessagesForAssistant(messages), [messages]);
const viewportRef = useRef<HTMLDivElement>(null);
const [openToolGroups, setOpenToolGroups] = useState<ReadonlySet<string>>(
() => new Set(activeToolGroupId ? [activeToolGroupId] : []),
);
@@ -260,6 +262,21 @@ export const AssistantOperatorThread = ({
if (activeToolGroupId) setOpenToolGroups(new Set([activeToolGroupId]));
}, [activeToolGroupId]);
useEffect(() => {
if (!activeToolGroupId) return;
// The same transcript can be much taller than the Scene 9 dock. Keep the
// beat-owned group visible without maintaining a second scroll-state model.
const viewport = viewportRef.current;
const activeGroup = viewport
?.querySelector<HTMLElement>(`[data-tool-group-id="${activeToolGroupId}"]`);
if (!viewport || !activeGroup) return;
const top = Math.max(
0,
activeGroup.offsetTop + activeGroup.offsetHeight - viewport.clientHeight,
);
viewport.scrollTop = top;
}, [activeToolGroupId, projected]);
const setToolGroupOpen = useCallback((groupId: string, open: boolean) => {
setOpenToolGroups((current) => {
const next = new Set(current);
@@ -283,7 +300,7 @@ export const AssistantOperatorThread = ({
aria-label={ariaLabel}
>
<div className="assistant-thread">
<div className="assistant-thread__viewport">
<div ref={viewportRef} className="assistant-thread__viewport">
{projected.map((message) => {
if (message.role === "user") {
return (