@truefoundry/agent-ui-sdk 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -80
- package/dist/index.d.ts +228 -281
- package/dist/index.js +1823 -1791
- package/dist/index.js.map +1 -1
- package/dist/styles.css +2 -0
- package/package.json +36 -19
- package/src/atoms/AskUserPrompt.tsx +0 -102
- package/src/atoms/AttachmentCard.tsx +0 -92
- package/src/atoms/AttachmentPickerButton.tsx +0 -19
- package/src/atoms/AttachmentPreviewDialog.tsx +0 -34
- package/src/atoms/BranchIndicator.tsx +0 -41
- package/src/atoms/CodeBlockHeader.tsx +0 -44
- package/src/atoms/ComposerShell.tsx +0 -112
- package/src/atoms/Markdown.test.tsx +0 -28
- package/src/atoms/Markdown.tsx +0 -288
- package/src/atoms/McpAuthPrompt.tsx +0 -60
- package/src/atoms/MessageActionBar.tsx +0 -60
- package/src/atoms/MessageBubble.tsx +0 -80
- package/src/atoms/MessageErrorBanner.tsx +0 -26
- package/src/atoms/MessageIndicator.tsx +0 -23
- package/src/atoms/OpenUIBlock.test.tsx +0 -37
- package/src/atoms/OpenUIBlock.tsx +0 -50
- package/src/atoms/ReasoningCard.tsx +0 -52
- package/src/atoms/SandboxArtifactList.tsx +0 -63
- package/src/atoms/ScrollToBottomButton.tsx +0 -34
- package/src/atoms/Skeletons.tsx +0 -32
- package/src/atoms/ThreadListMisc.tsx +0 -76
- package/src/atoms/ThreadListRow.tsx +0 -81
- package/src/atoms/ThreadShell.tsx +0 -93
- package/src/atoms/Toast.tsx +0 -60
- package/src/atoms/ToolApprovalBar.tsx +0 -92
- package/src/atoms/ToolCallCard.tsx +0 -186
- package/src/atoms/ToolGroupCard.tsx +0 -52
- package/src/atoms/UserMessageActionBar.tsx +0 -62
- package/src/atoms/WelcomeScreen.tsx +0 -22
- package/src/atoms/lib/cn.ts +0 -6
- package/src/atoms/primitives/Avatar.tsx +0 -57
- package/src/atoms/primitives/Button.tsx +0 -73
- package/src/atoms/primitives/Collapsible.tsx +0 -32
- package/src/atoms/primitives/Dialog.tsx +0 -152
- package/src/atoms/primitives/IconButton.tsx +0 -43
- package/src/atoms/primitives/Skeleton.tsx +0 -17
- package/src/atoms/primitives/Tooltip.tsx +0 -58
- package/src/containers/AskUserContainer.tsx +0 -49
- package/src/containers/AssistantMessageContainer.test.tsx +0 -56
- package/src/containers/AssistantMessageContainer.tsx +0 -128
- package/src/containers/AssistantTextContainer.test.tsx +0 -111
- package/src/containers/AssistantTextContainer.tsx +0 -54
- package/src/containers/AttachmentsContainer.tsx +0 -83
- package/src/containers/ComposerContainer.tsx +0 -66
- package/src/containers/ErrorToasterContainer.tsx +0 -76
- package/src/containers/McpAuthContainer.test.tsx +0 -100
- package/src/containers/McpAuthContainer.tsx +0 -22
- package/src/containers/MessageImageContainer.tsx +0 -37
- package/src/containers/ReasoningContainer.tsx +0 -32
- package/src/containers/RuntimeHarness.tsx +0 -39
- package/src/containers/Thread.tsx +0 -9
- package/src/containers/ThreadContainer.test.tsx +0 -52
- package/src/containers/ThreadContainer.tsx +0 -87
- package/src/containers/ThreadListContainer.tsx +0 -63
- package/src/containers/ToolCallContainer.test.tsx +0 -135
- package/src/containers/ToolCallContainer.tsx +0 -167
- package/src/containers/ToolGroupContainer.test.tsx +0 -56
- package/src/containers/ToolGroupContainer.tsx +0 -24
- package/src/containers/UserEditComposerContainer.test.tsx +0 -52
- package/src/containers/UserEditComposerContainer.tsx +0 -86
- package/src/containers/UserMessageContainer.test.tsx +0 -99
- package/src/containers/UserMessageContainer.tsx +0 -26
- package/src/containers/nestedApprovalBridge.ts +0 -20
- package/src/containers/useAttachmentPreviewSrc.ts +0 -42
- package/src/hooks/useComposerBusyState.test.ts +0 -78
- package/src/hooks/useComposerBusyState.ts +0 -69
- package/src/index.ts +0 -193
- package/src/testSetup.ts +0 -42
- package/src/theme/SlotsProvider.test.tsx +0 -64
- package/src/theme/SlotsProvider.tsx +0 -41
- package/src/theme/defaultSlots.ts +0 -131
- package/src/theme/tokens.ts +0 -98
- package/src/theme/useClassDarkMode.ts +0 -24
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
|
4
|
-
import { TrueFoundryGatewayError } from "truefoundry-gateway-sdk";
|
|
5
|
-
|
|
6
|
-
import { useSlot } from "../theme/SlotsProvider.js";
|
|
7
|
-
|
|
8
|
-
type ErrorToastContent = { title: string; description: string };
|
|
9
|
-
|
|
10
|
-
type ErrorToasterContextValue = { showError: (error: unknown) => void };
|
|
11
|
-
|
|
12
|
-
const ErrorToasterContext = createContext<ErrorToasterContextValue | null>(null);
|
|
13
|
-
|
|
14
|
-
function formatErrorBody(body: unknown): string | undefined {
|
|
15
|
-
if (body == null) return undefined;
|
|
16
|
-
if (typeof body === "string") return body;
|
|
17
|
-
try {
|
|
18
|
-
return JSON.stringify(body, null, 2);
|
|
19
|
-
} catch {
|
|
20
|
-
return String(body);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function normalizeError(error: unknown): ErrorToastContent {
|
|
25
|
-
if (error instanceof TrueFoundryGatewayError) {
|
|
26
|
-
const statusCode = error.statusCode;
|
|
27
|
-
const title = statusCode != null ? `Request failed (${statusCode})` : "Request failed";
|
|
28
|
-
const description = formatErrorBody(error.body) ?? (error.message || "The gateway returned an error.");
|
|
29
|
-
return { title, description };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (error instanceof Error) {
|
|
33
|
-
return { title: "Something went wrong", description: error.message || "An unexpected error occurred." };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return { title: "Something went wrong", description: String(error) };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function ErrorToasterProvider({ children }: { children: ReactNode }) {
|
|
40
|
-
const ToastStack = useSlot("ToastStack");
|
|
41
|
-
const Toast = useSlot("Toast");
|
|
42
|
-
|
|
43
|
-
const [toast, setToast] = useState<ErrorToastContent | null>(null);
|
|
44
|
-
const [open, setOpen] = useState(false);
|
|
45
|
-
|
|
46
|
-
const showError = useCallback((error: unknown) => {
|
|
47
|
-
setToast(normalizeError(error));
|
|
48
|
-
setOpen(true);
|
|
49
|
-
}, []);
|
|
50
|
-
|
|
51
|
-
const value = useMemo(() => ({ showError }), [showError]);
|
|
52
|
-
|
|
53
|
-
return (
|
|
54
|
-
<ErrorToasterContext.Provider value={value}>
|
|
55
|
-
<ToastStack>
|
|
56
|
-
{children}
|
|
57
|
-
{toast != null && (
|
|
58
|
-
<Toast title={toast.title} description={toast.description} open={open} onOpenChange={setOpen} />
|
|
59
|
-
)}
|
|
60
|
-
</ToastStack>
|
|
61
|
-
</ErrorToasterContext.Provider>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function useErrorToaster(): ErrorToasterContextValue {
|
|
66
|
-
const context = useContext(ErrorToasterContext);
|
|
67
|
-
if (context == null) {
|
|
68
|
-
throw new Error("useErrorToaster must be used within ErrorToasterProvider");
|
|
69
|
-
}
|
|
70
|
-
return context;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Same as `useErrorToaster`, but returns `null` instead of throwing outside `ErrorToasterProvider`. */
|
|
74
|
-
export function useErrorToasterOptional(): ErrorToasterContextValue | null {
|
|
75
|
-
return useContext(ErrorToasterContext);
|
|
76
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
-
import {
|
|
4
|
-
AssistantRuntimeProvider,
|
|
5
|
-
useExternalStoreRuntime,
|
|
6
|
-
type ThreadMessageLike,
|
|
7
|
-
} from "@assistant-ui/react";
|
|
8
|
-
import { trueFoundryExtras, type TrueFoundryRuntimeExtras } from "@truefoundry/assistant-ui-runtime";
|
|
9
|
-
import { describe, expect, it, vi } from "vitest";
|
|
10
|
-
|
|
11
|
-
import { McpAuthContainer } from "./McpAuthContainer.js";
|
|
12
|
-
|
|
13
|
-
const SERVERS = [
|
|
14
|
-
{ id: "srv-1", name: "github", authUrl: "https://example.com/auth/github" },
|
|
15
|
-
{ id: "srv-2", name: "slack", authUrl: "https://example.com/auth/slack" },
|
|
16
|
-
];
|
|
17
|
-
const PENDING = { mcpServers: SERVERS };
|
|
18
|
-
|
|
19
|
-
function McpAuthHarness({
|
|
20
|
-
pendingMcpAuth,
|
|
21
|
-
resumeMcpAuth,
|
|
22
|
-
isRunning = false,
|
|
23
|
-
}: {
|
|
24
|
-
pendingMcpAuth: TrueFoundryRuntimeExtras["pendingMcpAuth"];
|
|
25
|
-
resumeMcpAuth: TrueFoundryRuntimeExtras["resumeMcpAuth"];
|
|
26
|
-
isRunning?: boolean;
|
|
27
|
-
}) {
|
|
28
|
-
const messages: ThreadMessageLike[] = [];
|
|
29
|
-
const runtime = useExternalStoreRuntime({
|
|
30
|
-
messages,
|
|
31
|
-
isRunning,
|
|
32
|
-
convertMessage: (m: ThreadMessageLike) => m,
|
|
33
|
-
onNew: async () => {},
|
|
34
|
-
extras: trueFoundryExtras.provide({
|
|
35
|
-
pendingApprovals: [],
|
|
36
|
-
pendingToolResponses: [],
|
|
37
|
-
pendingMcpAuth,
|
|
38
|
-
sandboxId: undefined,
|
|
39
|
-
respondToToolApproval: () => {},
|
|
40
|
-
respondToToolResponse: () => {},
|
|
41
|
-
resumeMcpAuth,
|
|
42
|
-
downloadSandboxFile: async () => new Blob(),
|
|
43
|
-
cancel: async () => {},
|
|
44
|
-
resetFromTurn: async () => {},
|
|
45
|
-
draft: null,
|
|
46
|
-
}),
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<AssistantRuntimeProvider runtime={runtime}>
|
|
51
|
-
<McpAuthContainer />
|
|
52
|
-
</AssistantRuntimeProvider>
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
describe("McpAuthContainer", () => {
|
|
57
|
-
it("renders nothing when there is no pending MCP auth", () => {
|
|
58
|
-
render(<McpAuthHarness pendingMcpAuth={null} resumeMcpAuth={vi.fn()} />);
|
|
59
|
-
expect(screen.queryByRole("button", { name: /continue/i })).not.toBeInTheDocument();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("renders a real Connect link per server", () => {
|
|
63
|
-
render(
|
|
64
|
-
<McpAuthHarness
|
|
65
|
-
pendingMcpAuth={PENDING}
|
|
66
|
-
resumeMcpAuth={vi.fn().mockResolvedValue(undefined)}
|
|
67
|
-
/>,
|
|
68
|
-
);
|
|
69
|
-
expect(screen.getByText("github")).toBeInTheDocument();
|
|
70
|
-
expect(screen.getByText("slack")).toBeInTheDocument();
|
|
71
|
-
|
|
72
|
-
const connectLinks = screen.getAllByRole("link", { name: /connect/i });
|
|
73
|
-
expect(connectLinks).toHaveLength(2);
|
|
74
|
-
expect(connectLinks.map((link) => link.getAttribute("href"))).toEqual([
|
|
75
|
-
"https://example.com/auth/github",
|
|
76
|
-
"https://example.com/auth/slack",
|
|
77
|
-
]);
|
|
78
|
-
for (const link of connectLinks) {
|
|
79
|
-
expect(link).toHaveAttribute("target", "_blank");
|
|
80
|
-
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("calls resume when Continue is clicked", () => {
|
|
85
|
-
const resumeMcpAuth = vi.fn().mockResolvedValue(undefined);
|
|
86
|
-
render(
|
|
87
|
-
<McpAuthHarness pendingMcpAuth={PENDING} resumeMcpAuth={resumeMcpAuth} isRunning={false} />,
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
fireEvent.click(screen.getByRole("button", { name: /continue/i }));
|
|
91
|
-
expect(resumeMcpAuth).toHaveBeenCalledTimes(1);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("disables Continue while the thread is running", () => {
|
|
95
|
-
render(
|
|
96
|
-
<McpAuthHarness pendingMcpAuth={PENDING} resumeMcpAuth={vi.fn()} isRunning={true} />,
|
|
97
|
-
);
|
|
98
|
-
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
|
|
99
|
-
});
|
|
100
|
-
});
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useTrueFoundryMcpAuth } from "@truefoundry/assistant-ui-runtime";
|
|
4
|
-
import { useThreadIsRunning } from "@assistant-ui/core/react";
|
|
5
|
-
|
|
6
|
-
import { useSlot } from "../theme/SlotsProvider.js";
|
|
7
|
-
|
|
8
|
-
export function McpAuthContainer() {
|
|
9
|
-
const McpAuthPrompt = useSlot("McpAuthPrompt");
|
|
10
|
-
const { pending, resume } = useTrueFoundryMcpAuth();
|
|
11
|
-
const isRunning = useThreadIsRunning();
|
|
12
|
-
|
|
13
|
-
if (!pending) return null;
|
|
14
|
-
|
|
15
|
-
return (
|
|
16
|
-
<McpAuthPrompt
|
|
17
|
-
servers={pending.mcpServers}
|
|
18
|
-
disabled={isRunning}
|
|
19
|
-
onContinue={() => void resume()}
|
|
20
|
-
/>
|
|
21
|
-
);
|
|
22
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useAuiState } from "@assistant-ui/react";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
AttachmentCard,
|
|
7
|
-
USER_MESSAGE_ATTACHMENT_PREVIEW_REM,
|
|
8
|
-
} from "../atoms/AttachmentCard.js";
|
|
9
|
-
import { useSlot } from "../theme/SlotsProvider.js";
|
|
10
|
-
|
|
11
|
-
export function MessageImageContainer() {
|
|
12
|
-
const AttachmentPreviewDialog = useSlot("AttachmentPreviewDialog");
|
|
13
|
-
const image = useAuiState((s) => (s.part.type === "image" ? s.part.image : ""));
|
|
14
|
-
const filename = useAuiState((s) =>
|
|
15
|
-
s.part.type === "image" ? s.part.filename : undefined,
|
|
16
|
-
);
|
|
17
|
-
|
|
18
|
-
if (!image) {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const card = (
|
|
23
|
-
<AttachmentCard
|
|
24
|
-
name={filename ?? "image"}
|
|
25
|
-
previewSrc={image}
|
|
26
|
-
isImage
|
|
27
|
-
size="preview"
|
|
28
|
-
previewRem={USER_MESSAGE_ATTACHMENT_PREVIEW_REM}
|
|
29
|
-
/>
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<AttachmentPreviewDialog previewSrc={image}>
|
|
34
|
-
<div className="aui-message-image">{card}</div>
|
|
35
|
-
</AttachmentPreviewDialog>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, type PropsWithChildren } from "react";
|
|
4
|
-
import { useAuiState } from "@assistant-ui/react";
|
|
5
|
-
|
|
6
|
-
import { useSlot } from "../theme/SlotsProvider.js";
|
|
7
|
-
import type { ThreadGroupPart } from "./ToolGroupContainer.js";
|
|
8
|
-
|
|
9
|
-
export function ReasoningContainer({ group, children }: PropsWithChildren<{ group: ThreadGroupPart }>) {
|
|
10
|
-
const ReasoningCard = useSlot("ReasoningCard");
|
|
11
|
-
|
|
12
|
-
const streaming = useAuiState((s) => {
|
|
13
|
-
if (s.message.status?.type !== "running") return false;
|
|
14
|
-
const lastIndex = s.message.parts.length - 1;
|
|
15
|
-
if (lastIndex < 0) return false;
|
|
16
|
-
if (s.message.parts[lastIndex]?.type !== "reasoning") return false;
|
|
17
|
-
return lastIndex >= group.indices[0]! && lastIndex <= group.indices[group.indices.length - 1]!;
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const [expanded, setExpanded] = useState(streaming);
|
|
21
|
-
const [prevStreaming, setPrevStreaming] = useState(streaming);
|
|
22
|
-
if (streaming !== prevStreaming) {
|
|
23
|
-
setPrevStreaming(streaming);
|
|
24
|
-
if (streaming) setExpanded(true);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<ReasoningCard streaming={streaming} expanded={expanded} onToggle={() => setExpanded((prev) => !prev)}>
|
|
29
|
-
{children}
|
|
30
|
-
</ReasoningCard>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import type { ReactNode } from "react";
|
|
4
|
-
import {
|
|
5
|
-
AssistantRuntimeProvider,
|
|
6
|
-
useExternalStoreRuntime,
|
|
7
|
-
type AppendMessage,
|
|
8
|
-
type RespondToToolApprovalOptions,
|
|
9
|
-
type ThreadMessageLike,
|
|
10
|
-
} from "@assistant-ui/react";
|
|
11
|
-
|
|
12
|
-
/** Test-only harness driving a real assistant-ui runtime from plain message fixtures. */
|
|
13
|
-
export function RuntimeHarness({
|
|
14
|
-
messages,
|
|
15
|
-
isRunning = false,
|
|
16
|
-
isLoading = false,
|
|
17
|
-
onRespondToToolApproval,
|
|
18
|
-
onEdit,
|
|
19
|
-
children,
|
|
20
|
-
}: {
|
|
21
|
-
messages: ThreadMessageLike[];
|
|
22
|
-
isRunning?: boolean;
|
|
23
|
-
isLoading?: boolean;
|
|
24
|
-
onRespondToToolApproval?: (options: RespondToToolApprovalOptions) => void;
|
|
25
|
-
onEdit?: (message: AppendMessage) => Promise<void>;
|
|
26
|
-
children: ReactNode;
|
|
27
|
-
}) {
|
|
28
|
-
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
|
|
29
|
-
messages,
|
|
30
|
-
isRunning,
|
|
31
|
-
isLoading,
|
|
32
|
-
convertMessage: (message) => message,
|
|
33
|
-
onNew: async () => {},
|
|
34
|
-
onEdit,
|
|
35
|
-
onRespondToToolApproval,
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
|
|
39
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { ThreadContainer } from "./ThreadContainer.js";
|
|
4
|
-
import { ComposerContainer } from "./ComposerContainer.js";
|
|
5
|
-
|
|
6
|
-
/** The full assembled thread view: message list + composer. */
|
|
7
|
-
export function Thread() {
|
|
8
|
-
return <ThreadContainer composer={<ComposerContainer />} />;
|
|
9
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
3
|
-
import type { ThreadMessageLike } from "@assistant-ui/react";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { RuntimeHarness } from "./RuntimeHarness.js";
|
|
7
|
-
import { ThreadContainer } from "./ThreadContainer.js";
|
|
8
|
-
|
|
9
|
-
function renderThread(
|
|
10
|
-
messages: ThreadMessageLike[],
|
|
11
|
-
options?: { isLoading?: boolean; composer?: React.ReactNode },
|
|
12
|
-
) {
|
|
13
|
-
return render(
|
|
14
|
-
<RuntimeHarness messages={messages} isLoading={options?.isLoading}>
|
|
15
|
-
<ThreadContainer composer={options?.composer} />
|
|
16
|
-
</RuntimeHarness>,
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe("ThreadContainer", () => {
|
|
21
|
-
it("renders the welcome screen for a new, empty thread", () => {
|
|
22
|
-
renderThread([]);
|
|
23
|
-
expect(screen.getByText("How can I help you today?")).toBeInTheDocument();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("renders the loading skeleton while thread history is loading", () => {
|
|
27
|
-
renderThread([], { isLoading: true });
|
|
28
|
-
expect(screen.getByRole("status", { name: "Loading conversation" })).toBeInTheDocument();
|
|
29
|
-
expect(screen.queryByText("How can I help you today?")).not.toBeInTheDocument();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("renders the message list once loaded and non-empty", () => {
|
|
33
|
-
renderThread([
|
|
34
|
-
{ role: "user", content: "hi" },
|
|
35
|
-
{ role: "assistant", content: "hello there" },
|
|
36
|
-
]);
|
|
37
|
-
expect(screen.getByText("hello there")).toBeInTheDocument();
|
|
38
|
-
expect(screen.queryByText("How can I help you today?")).not.toBeInTheDocument();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("renders the supplied composer slot", () => {
|
|
42
|
-
renderThread([{ role: "user", content: "hi" }], {
|
|
43
|
-
composer: <div data-testid="composer-slot" />,
|
|
44
|
-
});
|
|
45
|
-
expect(screen.getByTestId("composer-slot")).toBeInTheDocument();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("omits the composer area entirely while loading", () => {
|
|
49
|
-
renderThread([], { isLoading: true, composer: <div data-testid="composer-slot" /> });
|
|
50
|
-
expect(screen.queryByTestId("composer-slot")).not.toBeInTheDocument();
|
|
51
|
-
});
|
|
52
|
-
});
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import type { ReactNode } from "react";
|
|
4
|
-
import { ThreadPrimitive, useAuiState, type AssistantState } from "@assistant-ui/react";
|
|
5
|
-
|
|
6
|
-
import { useSlot } from "../theme/SlotsProvider.js";
|
|
7
|
-
import { ComposerBusyProvider } from "../hooks/useComposerBusyState.js";
|
|
8
|
-
import { AssistantMessageContainer } from "./AssistantMessageContainer.js";
|
|
9
|
-
import { UserEditComposerContainer } from "./UserEditComposerContainer.js";
|
|
10
|
-
import { UserMessageContainer } from "./UserMessageContainer.js";
|
|
11
|
-
|
|
12
|
-
// Startup exposes a loading placeholder thread; treat it as a new chat so the
|
|
13
|
-
// composer mounts centered. Loads after startup keep the docked layout.
|
|
14
|
-
const isNewChatView = (s: AssistantState) =>
|
|
15
|
-
s.thread.messages.length === 0 && (!s.thread.isLoading || s.threads.isLoading);
|
|
16
|
-
|
|
17
|
-
function ThreadMessage({ isEditing }: { isEditing: boolean }) {
|
|
18
|
-
const role = useAuiState((s) => s.message.role);
|
|
19
|
-
if (role === "user") {
|
|
20
|
-
if (isEditing) {
|
|
21
|
-
return <UserEditComposerContainer />;
|
|
22
|
-
}
|
|
23
|
-
return <UserMessageContainer />;
|
|
24
|
-
}
|
|
25
|
-
return <AssistantMessageContainer />;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export type ThreadContainerProps = {
|
|
29
|
-
/**
|
|
30
|
-
* Rendered in the bottom composer area. Left undefined until
|
|
31
|
-
* `ComposerContainer` exists (milestone 6); the public `<Thread/>` export
|
|
32
|
-
* wires the two together once it does.
|
|
33
|
-
*/
|
|
34
|
-
composer?: ReactNode;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export function ThreadContainer({ composer }: ThreadContainerProps) {
|
|
38
|
-
const ThreadRootShell = useSlot("ThreadRootShell");
|
|
39
|
-
const ThreadViewportShell = useSlot("ThreadViewportShell");
|
|
40
|
-
const ThreadComposerAreaShell = useSlot("ThreadComposerAreaShell");
|
|
41
|
-
const MessageGroup = useSlot("MessageGroup");
|
|
42
|
-
const WelcomeScreen = useSlot("WelcomeScreen");
|
|
43
|
-
const MessageListSkeleton = useSlot("MessageListSkeleton");
|
|
44
|
-
const ScrollToBottomButton = useSlot("ScrollToBottomButton");
|
|
45
|
-
|
|
46
|
-
const isEmpty = useAuiState(isNewChatView);
|
|
47
|
-
const isLoading = useAuiState((s) => s.thread.isLoading);
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<ComposerBusyProvider>
|
|
51
|
-
<ThreadPrimitive.Root asChild>
|
|
52
|
-
<ThreadRootShell>
|
|
53
|
-
<ThreadPrimitive.Viewport asChild turnAnchor="top" autoScroll>
|
|
54
|
-
<ThreadViewportShell isEmpty={isEmpty}>
|
|
55
|
-
{isEmpty && <WelcomeScreen />}
|
|
56
|
-
{isLoading ? (
|
|
57
|
-
<MessageListSkeleton />
|
|
58
|
-
) : (
|
|
59
|
-
<MessageGroup>
|
|
60
|
-
<ThreadPrimitive.Messages>
|
|
61
|
-
{({ message }) => (
|
|
62
|
-
<ThreadMessage
|
|
63
|
-
isEditing={
|
|
64
|
-
message.role === "user" &&
|
|
65
|
-
message.composer.isEditing
|
|
66
|
-
}
|
|
67
|
-
/>
|
|
68
|
-
)}
|
|
69
|
-
</ThreadPrimitive.Messages>
|
|
70
|
-
</MessageGroup>
|
|
71
|
-
)}
|
|
72
|
-
</ThreadViewportShell>
|
|
73
|
-
</ThreadPrimitive.Viewport>
|
|
74
|
-
|
|
75
|
-
{!isLoading && (
|
|
76
|
-
<ThreadComposerAreaShell isEmpty={isEmpty}>
|
|
77
|
-
<ThreadPrimitive.ScrollToBottom asChild>
|
|
78
|
-
<ScrollToBottomButton />
|
|
79
|
-
</ThreadPrimitive.ScrollToBottom>
|
|
80
|
-
{composer}
|
|
81
|
-
</ThreadComposerAreaShell>
|
|
82
|
-
)}
|
|
83
|
-
</ThreadRootShell>
|
|
84
|
-
</ThreadPrimitive.Root>
|
|
85
|
-
</ComposerBusyProvider>
|
|
86
|
-
);
|
|
87
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useAui, useAuiState } from "@assistant-ui/react";
|
|
4
|
-
|
|
5
|
-
import { useSlot } from "../theme/SlotsProvider.js";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Simplified relative to the reference: renders threads in a single flat list
|
|
9
|
-
* rather than grouping them by Today/Yesterday/Earlier. Archive/Delete are
|
|
10
|
-
* wired but no-op against the current thread-list adapter (documented in the
|
|
11
|
-
* runtime README's unsupported-features table).
|
|
12
|
-
*/
|
|
13
|
-
export function ThreadListContainer() {
|
|
14
|
-
const ThreadListShell = useSlot("ThreadListShell");
|
|
15
|
-
const ThreadListNewButton = useSlot("ThreadListNewButton");
|
|
16
|
-
const ThreadListRow = useSlot("ThreadListRow");
|
|
17
|
-
const ThreadListRowSkeleton = useSlot("ThreadListRowSkeleton");
|
|
18
|
-
const ThreadListEmptyState = useSlot("ThreadListEmptyState");
|
|
19
|
-
const Button = useSlot("Button");
|
|
20
|
-
|
|
21
|
-
const aui = useAui();
|
|
22
|
-
const isLoading = useAuiState((s) => s.threads.isLoading);
|
|
23
|
-
const isLoadingMore = useAuiState((s) => s.threads.isLoadingMore);
|
|
24
|
-
const hasMore = useAuiState((s) => s.threads.hasMore);
|
|
25
|
-
const threadIds = useAuiState((s) => s.threads.threadIds);
|
|
26
|
-
const threadItems = useAuiState((s) => s.threads.threadItems);
|
|
27
|
-
const mainThreadId = useAuiState((s) => s.threads.mainThreadId);
|
|
28
|
-
|
|
29
|
-
const itemsById = new Map(threadItems.map((item) => [item.id, item]));
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
<ThreadListShell header={<ThreadListNewButton onClick={() => aui.threads().switchToNewThread()} />}>
|
|
33
|
-
{isLoading ? (
|
|
34
|
-
<ThreadListRowSkeleton />
|
|
35
|
-
) : threadIds.length === 0 ? (
|
|
36
|
-
<ThreadListEmptyState />
|
|
37
|
-
) : (
|
|
38
|
-
threadIds.map((id) => {
|
|
39
|
-
const item = itemsById.get(id);
|
|
40
|
-
return (
|
|
41
|
-
<ThreadListRow
|
|
42
|
-
key={id}
|
|
43
|
-
title={item?.title ?? "New Chat"}
|
|
44
|
-
active={id === mainThreadId}
|
|
45
|
-
onSelect={() => aui.threads().switchToThread(id)}
|
|
46
|
-
onArchive={() => aui.threads().item({ id }).archive()}
|
|
47
|
-
onDelete={() => aui.threads().item({ id }).delete()}
|
|
48
|
-
/>
|
|
49
|
-
);
|
|
50
|
-
})
|
|
51
|
-
)}
|
|
52
|
-
{!isLoading &&
|
|
53
|
-
hasMore &&
|
|
54
|
-
(isLoadingMore ? (
|
|
55
|
-
<ThreadListRowSkeleton count={1} />
|
|
56
|
-
) : (
|
|
57
|
-
<Button variant="ghost" onClick={() => void aui.threads().loadMore()}>
|
|
58
|
-
Load more
|
|
59
|
-
</Button>
|
|
60
|
-
))}
|
|
61
|
-
</ThreadListShell>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
-
import { ThreadPrimitive, type ThreadMessageLike } from "@assistant-ui/react";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { RuntimeHarness } from "./RuntimeHarness.js";
|
|
7
|
-
import { AssistantMessageContainer } from "./AssistantMessageContainer.js";
|
|
8
|
-
|
|
9
|
-
function renderToolCallMessage(content: ThreadMessageLike["content"]) {
|
|
10
|
-
const message: ThreadMessageLike = { role: "assistant", content };
|
|
11
|
-
return render(
|
|
12
|
-
<RuntimeHarness messages={[message]}>
|
|
13
|
-
<ThreadPrimitive.Messages>{() => <AssistantMessageContainer />}</ThreadPrimitive.Messages>
|
|
14
|
-
</RuntimeHarness>,
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Pending approvals only surface part-level "requires-action" when the whole message is flagged too. */
|
|
19
|
-
function renderPendingApprovalMessage(
|
|
20
|
-
content: ThreadMessageLike["content"],
|
|
21
|
-
options?: { onRespondToToolApproval?: (o: unknown) => void },
|
|
22
|
-
) {
|
|
23
|
-
const message: ThreadMessageLike = {
|
|
24
|
-
role: "assistant",
|
|
25
|
-
content,
|
|
26
|
-
status: { type: "requires-action", reason: "tool-calls" },
|
|
27
|
-
};
|
|
28
|
-
return render(
|
|
29
|
-
<RuntimeHarness
|
|
30
|
-
messages={[message]}
|
|
31
|
-
onRespondToToolApproval={options?.onRespondToToolApproval}
|
|
32
|
-
>
|
|
33
|
-
<ThreadPrimitive.Messages>{() => <AssistantMessageContainer />}</ThreadPrimitive.Messages>
|
|
34
|
-
</RuntimeHarness>,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe("ToolCallContainer", () => {
|
|
39
|
-
it("renders a running tool call without a result", () => {
|
|
40
|
-
renderToolCallMessage([{ type: "tool-call", toolCallId: "1", toolName: "search_docs", args: {} }]);
|
|
41
|
-
expect(screen.getByText("search_docs")).toBeInTheDocument();
|
|
42
|
-
expect(screen.queryByText("Result:")).not.toBeInTheDocument();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("renders args and result for a completed tool call once expanded", () => {
|
|
46
|
-
renderToolCallMessage([
|
|
47
|
-
{
|
|
48
|
-
type: "tool-call",
|
|
49
|
-
toolCallId: "1",
|
|
50
|
-
toolName: "get_current_datetime",
|
|
51
|
-
args: {},
|
|
52
|
-
argsText: '{"tz":"UTC"}',
|
|
53
|
-
result: "2026-07-01T00:00:00Z",
|
|
54
|
-
},
|
|
55
|
-
]);
|
|
56
|
-
fireEvent.click(screen.getByText("get_current_datetime"));
|
|
57
|
-
expect(screen.getByText('{"tz":"UTC"}')).toBeInTheDocument();
|
|
58
|
-
expect(screen.getByText("2026-07-01T00:00:00Z")).toBeInTheDocument();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("renders an error result once expanded", () => {
|
|
62
|
-
renderToolCallMessage([
|
|
63
|
-
{
|
|
64
|
-
type: "tool-call",
|
|
65
|
-
toolCallId: "1",
|
|
66
|
-
toolName: "flaky_tool",
|
|
67
|
-
args: {},
|
|
68
|
-
result: "boom",
|
|
69
|
-
isError: true,
|
|
70
|
-
},
|
|
71
|
-
]);
|
|
72
|
-
fireEvent.click(screen.getByText("flaky_tool"));
|
|
73
|
-
const result = screen.getByText("boom");
|
|
74
|
-
expect(result).toBeInTheDocument();
|
|
75
|
-
expect(result).toHaveClass("text-destructive");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("auto-expands and shows the approval bar while an approval is pending", () => {
|
|
79
|
-
renderPendingApprovalMessage([
|
|
80
|
-
{
|
|
81
|
-
type: "tool-call",
|
|
82
|
-
toolCallId: "1",
|
|
83
|
-
toolName: "delete_file",
|
|
84
|
-
args: {},
|
|
85
|
-
interrupt: { type: "human", payload: {} },
|
|
86
|
-
approval: { id: "approval-1", approved: undefined },
|
|
87
|
-
},
|
|
88
|
-
]);
|
|
89
|
-
expect(screen.getByRole("button", { name: "Allow" })).toBeInTheDocument();
|
|
90
|
-
expect(screen.getByRole("button", { name: "Deny" })).toBeInTheDocument();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("calls onRespondToToolApproval when Allow is clicked", () => {
|
|
94
|
-
const onRespondToToolApproval = vi.fn();
|
|
95
|
-
renderPendingApprovalMessage(
|
|
96
|
-
[
|
|
97
|
-
{
|
|
98
|
-
type: "tool-call",
|
|
99
|
-
toolCallId: "1",
|
|
100
|
-
toolName: "delete_file",
|
|
101
|
-
args: {},
|
|
102
|
-
interrupt: { type: "human", payload: {} },
|
|
103
|
-
approval: { id: "approval-1", approved: undefined },
|
|
104
|
-
},
|
|
105
|
-
],
|
|
106
|
-
{ onRespondToToolApproval },
|
|
107
|
-
);
|
|
108
|
-
fireEvent.click(screen.getByRole("button", { name: "Allow" }));
|
|
109
|
-
expect(onRespondToToolApproval).toHaveBeenCalledWith(
|
|
110
|
-
expect.objectContaining({ approvalId: "approval-1", approved: true }),
|
|
111
|
-
);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("renders declared approval options with their labels", () => {
|
|
115
|
-
renderPendingApprovalMessage([
|
|
116
|
-
{
|
|
117
|
-
type: "tool-call",
|
|
118
|
-
toolCallId: "1",
|
|
119
|
-
toolName: "run_migration",
|
|
120
|
-
args: {},
|
|
121
|
-
interrupt: { type: "human", payload: {} },
|
|
122
|
-
approval: {
|
|
123
|
-
id: "approval-1",
|
|
124
|
-
approved: undefined,
|
|
125
|
-
options: [
|
|
126
|
-
{ id: "opt-allow", kind: "allow-once" },
|
|
127
|
-
{ id: "opt-deny", kind: "reject-once" },
|
|
128
|
-
],
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
]);
|
|
132
|
-
expect(screen.getByRole("button", { name: "Allow" })).toBeInTheDocument();
|
|
133
|
-
expect(screen.getByRole("button", { name: "Deny" })).toBeInTheDocument();
|
|
134
|
-
});
|
|
135
|
-
});
|