Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/docs/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ const majorChords = [
"Sidebar thread list with new thread button, loading skeletons, and context menus.",
href: "/docs/major-chords/thread-list",
},
{
name: "ToolGroup",
description:
"Collapsible container grouping consecutive tool calls with count badge and spinner.",
href: "/docs/major-chords/tool-group",
},
];

const minorChords = [
Expand Down
125 changes: 125 additions & 0 deletions apps/docs/components/demo/tool-group-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"use client";

import {
AssistantRuntimeProvider,
ThreadPrimitive,
MessagePrimitive,
ComposerPrimitive,
useLocalRuntime,
} from "@assistant-ui/react";
import {
ToolCallRenderer,
ToolGroup,
ComposerActionStatus,
} from "@assistant-ui/chords";
import type { ChatModelAdapter } from "@assistant-ui/react";
import { FC } from "react";

const demoAdapter: ChatModelAdapter = {
async *run({ abortSignal }) {
const toolCallId1 = `call_${Date.now()}_1`;
const toolCallId2 = `call_${Date.now()}_2`;

const toolCall1 = {
type: "tool-call" as const,
toolCallId: toolCallId1,
toolName: "get_weather",
args: { location: "Paris", units: "celsius" },
argsText: JSON.stringify({ location: "Paris", units: "celsius" }),
};
const toolCall2 = {
type: "tool-call" as const,
toolCallId: toolCallId2,
toolName: "get_time",
args: { timezone: "CET", format: "24h" },
argsText: JSON.stringify({ timezone: "CET", format: "24h" }),
};

// Yield two tool calls running
yield { content: [toolCall1] };
yield { content: [toolCall1, toolCall2] };

await new Promise((r) => setTimeout(r, 1500));
if (abortSignal.aborted) return;

// Yield with results + text
yield {
content: [
{ ...toolCall1, result: { temperature: 22, condition: "Sunny" } },
{ ...toolCall2, result: { time: "14:30" } },
{
type: "text" as const,
text: "It's 22°C and sunny in Paris. The local time is 14:30 CET.",
},
],
};
},
};

export function ToolGroupDemo() {
const runtime = useLocalRuntime(demoAdapter);

return (
<AssistantRuntimeProvider runtime={runtime}>
<div className="overflow-hidden rounded-xl border border-zinc-400 dark:border-zinc-800">
<ThreadPrimitive.Root className="flex h-80 flex-col bg-white dark:bg-zinc-950">
<div className="relative min-h-0 flex-1">
<ThreadPrimitive.Viewport className="flex h-full flex-col gap-2 overflow-y-auto px-3 pt-3 pb-3">
<ThreadPrimitive.Messages
components={{
UserMessage: DemoUserMessage,
AssistantMessage: DemoAssistantMessage,
}}
/>
</ThreadPrimitive.Viewport>
</div>
<div className="border-t border-zinc-200 dark:border-zinc-800">
<ComposerPrimitive.Root className="flex items-center gap-1 px-2">
<ComposerPrimitive.Input
placeholder="Type a message..."
className="h-9 flex-1 resize-none bg-transparent px-2 text-xs text-zinc-900 outline-none placeholder:text-zinc-400 dark:text-white dark:placeholder:text-white/50"
/>
<ComposerActionStatus />
</ComposerPrimitive.Root>
</div>
<div className="border-t border-zinc-200 px-3 py-2 dark:border-zinc-800">
<ThreadPrimitive.If running={false}>
<ThreadPrimitive.Suggestion
prompt="Check the weather and time in Paris"
send
className="w-full rounded-lg bg-zinc-100 px-3 py-2 text-left text-xs text-zinc-600 transition-colors hover:bg-zinc-200 dark:bg-white/5 dark:text-white/60 dark:hover:bg-white/10"
>
Check weather & time in Paris
</ThreadPrimitive.Suggestion>
</ThreadPrimitive.If>
</div>
</ThreadPrimitive.Root>
</div>
</AssistantRuntimeProvider>
);
}

const DemoUserMessage: FC = () => (
<MessagePrimitive.Root className="flex w-full flex-col items-end">
<div className="max-w-[80%] rounded-2xl bg-zinc-100 px-3 py-1.5 text-xs text-zinc-900 dark:bg-white/10 dark:text-white/90">
<MessagePrimitive.Content />
</div>
</MessagePrimitive.Root>
);

const DemoAssistantMessage: FC = () => (
<MessagePrimitive.Root className="flex w-full gap-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-full border border-zinc-300 text-[10px] shadow dark:border-white/15">
A
</div>
<div className="flex-1 pt-0.5 text-xs text-zinc-900 dark:text-white/90">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => <span>{text}</span>,
tools: { Fallback: ToolCallRenderer },
ToolGroup: ToolGroup,
}}
/>
</div>
</MessagePrimitive.Root>
);
14 changes: 14 additions & 0 deletions apps/docs/components/playground/code-generators/generate-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,20 @@ const generators: Record<ChordId, (config: ChordConfig) => CodeGenResult> = {
ThreadListItem={() => (
<ThreadListItem${itemProps} />
)}
/>`,
};
},
"tool-group": (config) => {
const defaults = chordRegistry["tool-group"].defaultConfig;
const props = buildPropsString(config, defaults);
return {
imports: `import { ToolCallRenderer, ToolGroup } from "@assistant-ui/chords";`,
jsx: `<MessagePrimitive.Parts
components={{
Text: ({ text }) => <span>{text}</span>,
tools: { Fallback: ToolCallRenderer },
ToolGroup: ToolGroup,
}}
/>`,
};
},
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/playground/controls-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ScrollToBottomControls } from "./controls/scroll-to-bottom-controls";
import { ReasoningAccordionControls } from "./controls/reasoning-accordion-controls";
import { FeedbackButtonsControls } from "./controls/feedback-buttons-controls";
import { ThreadListControls } from "./controls/thread-list-controls";
import { ToolGroupControls } from "./controls/tool-group-controls";

type ControlsPanelProps = {
chordId: ChordId;
Expand All @@ -39,6 +40,7 @@ const controlsMap: Partial<
"reasoning-accordion": ReasoningAccordionControls,
"feedback-buttons": FeedbackButtonsControls,
"thread-list": ThreadListControls,
"tool-group": ToolGroupControls,
};

export function ControlsPanel({ chordId, config, onChange }: ControlsPanelProps) {
Expand Down
47 changes: 47 additions & 0 deletions apps/docs/components/playground/controls/tool-group-controls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";

import type { ChordConfig } from "@/lib/playground/types";
import { StyleBuilder, CheckboxInput } from "./shared";

export function ToolGroupControls({
config,
onChange,
}: {
config: ChordConfig;
onChange: (c: ChordConfig) => void;
}) {
return (
<div className="flex flex-col gap-3">
<CheckboxInput
label="defaultOpen"
checked={(config.defaultOpen as boolean) ?? false}
onChange={(v) => onChange({ ...config, defaultOpen: v })}
/>
<StyleBuilder
label="className"
value={(config.className as string) ?? ""}
defaultValue=""
onChange={(v) => onChange({ ...config, className: v || undefined })}
controls={["bg", "rounded", "p"]}
/>
<StyleBuilder
label="triggerClassName"
value={(config.triggerClassName as string) ?? ""}
defaultValue=""
onChange={(v) =>
onChange({ ...config, triggerClassName: v || undefined })
}
controls={["bg", "text"]}
/>
<StyleBuilder
label="contentClassName"
value={(config.contentClassName as string) ?? ""}
defaultValue=""
onChange={(v) =>
onChange({ ...config, contentClassName: v || undefined })
}
controls={["bg", "text"]}
/>
</div>
);
}
2 changes: 2 additions & 0 deletions apps/docs/components/playground/preview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ScrollToBottomPreview } from "./previews/scroll-to-bottom-preview";
import { ReasoningAccordionPreview } from "./previews/reasoning-accordion-preview";
import { FeedbackButtonsPreview } from "./previews/feedback-buttons-preview";
import { ThreadListPreview } from "./previews/thread-list-preview";
import { ToolGroupPreview } from "./previews/tool-group-preview";

type PreviewPanelProps = {
chordId: ChordId;
Expand All @@ -42,6 +43,7 @@ const previewMap: Record<ChordId, React.FC<{ config: ChordConfig }>> = {
"reasoning-accordion": ReasoningAccordionPreview,
"feedback-buttons": FeedbackButtonsPreview,
"thread-list": ThreadListPreview,
"tool-group": ToolGroupPreview,
};

export function PreviewPanel({ chordId, config }: PreviewPanelProps) {
Expand Down
130 changes: 130 additions & 0 deletions apps/docs/components/playground/previews/tool-group-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"use client";

import {
AssistantRuntimeProvider,
ThreadPrimitive,
MessagePrimitive,
ComposerPrimitive,
useLocalRuntime,
} from "@assistant-ui/react";
import {
ToolCallRenderer,
ToolGroup,
ComposerActionStatus,
} from "@assistant-ui/chords";
import type { ChordConfig } from "@/lib/playground/types";
import type { ChatModelAdapter } from "@assistant-ui/react";
import { useRef } from "react";

const toolAdapter: ChatModelAdapter = {
async *run({ abortSignal }) {
const id1 = `call_${Date.now()}_1`;
const id2 = `call_${Date.now()}_2`;

const tc1 = {
type: "tool-call" as const,
toolCallId: id1,
toolName: "search",
args: { query: "hello" },
argsText: JSON.stringify({ query: "hello" }),
};
const tc2 = {
type: "tool-call" as const,
toolCallId: id2,
toolName: "fetch",
args: { url: "https://example.com" },
argsText: JSON.stringify({ url: "https://example.com" }),
};

yield { content: [tc1] };
yield { content: [tc1, tc2] };

await new Promise((r) => setTimeout(r, 1200));
if (abortSignal.aborted) return;

yield {
content: [
{ ...tc1, result: { hits: 42 } },
{ ...tc2, result: { status: 200 } },
{
type: "text" as const,
text: "Found 42 results. The page returned status 200.",
},
],
};
},
};

export function ToolGroupPreview({ config }: { config: ChordConfig }) {
const adapter = useRef(toolAdapter);
const runtime = useLocalRuntime(adapter.current);

return (
<AssistantRuntimeProvider runtime={runtime}>
<div className="overflow-hidden rounded-xl border border-zinc-300 dark:border-zinc-800">
<ThreadPrimitive.Root className="flex h-72 flex-col bg-white dark:bg-zinc-950">
<div className="relative min-h-0 flex-1">
<ThreadPrimitive.Viewport className="flex h-full flex-col gap-2 overflow-y-auto px-3 pt-3 pb-3">
<ThreadPrimitive.Messages
components={{
UserMessage: () => (
<MessagePrimitive.Root className="flex w-full flex-col items-end">
<div className="max-w-[80%] rounded-2xl bg-zinc-100 px-3 py-1.5 text-xs text-zinc-900 dark:bg-white/10 dark:text-white/90">
<MessagePrimitive.Content />
</div>
</MessagePrimitive.Root>
),
AssistantMessage: () => (
<MessagePrimitive.Root className="flex w-full gap-2">
<div className="flex size-5 shrink-0 items-center justify-center rounded-full border border-zinc-300 text-[9px] dark:border-white/15">
A
</div>
<div className="flex-1 text-xs text-zinc-900 dark:text-white/90">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => <span>{text}</span>,
tools: { Fallback: ToolCallRenderer },
ToolGroup: (props) => (
<ToolGroup
{...props}
className={
(config.className as string) || undefined
}
triggerClassName={
(config.triggerClassName as string) ||
undefined
}
contentClassName={
(config.contentClassName as string) ||
undefined
}
defaultOpen={
(config.defaultOpen as boolean) ?? false
}
/>
),
}}
/>
</div>
</MessagePrimitive.Root>
),
}}
/>
</ThreadPrimitive.Viewport>
</div>
<div className="border-t border-zinc-200 px-3 py-2 dark:border-zinc-800">
<ThreadPrimitive.If running={false}>
<ThreadPrimitive.Suggestion
prompt="Search and fetch something"
send
className="w-full rounded-lg bg-zinc-100 px-3 py-1.5 text-left text-xs text-zinc-600 transition-colors hover:bg-zinc-200 dark:bg-white/5 dark:text-white/60 dark:hover:bg-white/10"
>
Run tool calls
</ThreadPrimitive.Suggestion>
</ThreadPrimitive.If>
</div>
</ThreadPrimitive.Root>
</div>
</AssistantRuntimeProvider>
);
}
3 changes: 2 additions & 1 deletion apps/docs/content/docs/major-chords/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"tool-call-renderer",
"reasoning-accordion",
"feedback-buttons",
"thread-list"
"thread-list",
"tool-group"
]
}
Loading