Skip to content
15 changes: 8 additions & 7 deletions docs/api-reference/veryfront/chat.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions docs/rfcs/29-chat-api-shape/components/chat-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ The conversation list - browse, create, rename, and delete conversation threads.

> **Status: RFC 29 - proposed; nothing on this page has landed.** Per-symbol truth, verified against `src/` by `deno task lint:rfc-status`:
>
> - **Exported from `veryfront/chat` today:** `ChatSidebar`, `ChatSidebar.Empty`, `ChatSidebar.Group`, `ChatSidebar.Item`, `ChatSidebar.Item.Delete`, `ChatSidebar.Item.Menu`, `ChatSidebar.Item.Rename`, `ChatSidebar.List`, `ChatSidebar.NewButton`, `ChatSidebar.Root`
> - **Not exported today:** `ChatSidebar.Item.Menu.Trigger`, `ChatSidebar.Item.Title`
> - **Exported from `veryfront/chat` today:** `ChatSidebar`, `ChatSidebar.Empty`, `ChatSidebar.Group`, `ChatSidebar.Item`, `ChatSidebar.Item.Delete`, `ChatSidebar.Item.Menu`, `ChatSidebar.Item.Rename`, `ChatSidebar.Item.Title`, `ChatSidebar.List`, `ChatSidebar.NewButton`, `ChatSidebar.Root`
> - **Not exported today:** `ChatSidebar.Item.Menu.Trigger`
>
> An exported symbol is not a landed delta - see [reading the status block](../README.md#reading-the-status-block). Full rationale: [`29-chat-api-shape.md`](../../29-chat-api-shape.md).

Expand All @@ -27,7 +27,7 @@ import { ChatSidebar, ChatSidebarItemTitle, type ChatSidebarItemTitleProps } fro
- [`.List`](#chatsidebarlist---changed) - `changed`: `<div>` → `<ul>`; `renderItem` deleted
- [`.Group`](#chatsidebargroup---kept) - `kept`
- [`.Item`](#chatsidebaritem---changed) - `changed`: `<div>` → `<li>`; `children` replaces the whole row; `data-active` means selection
- [`.Item.Title`](#chatsidebaritemtitle-_proposed---2977_---new) - `new`: no addressable title leaf exists today (#2977)
- [`.Item.Title`](#chatsidebaritemtitle-proposed---2977---new) - `new`: the addressable title leaf - the export ships today; full row recomposition is still proposed (#2977)
- [`.Item.Menu`](#chatsidebaritemmenu---changed) - `changed`: trigger `icon` prop deleted - replaced by `.Item.Menu.Trigger`
- [`.Item.Menu.Trigger`](#chatsidebaritemmenutrigger---new) - `new`: the addressable trigger button (the icon-slot replacement)
- [`.Item.Rename`](#chatsidebaritemrename---changed) - `changed`: `icon` deleted
Expand Down Expand Up @@ -196,7 +196,7 @@ Per-row state computed today: `isActive = conversation.id === activeId`; the row

### `ChatSidebar.Item.Title` _(proposed - #2977)_ - `new`

One `<span>`: the conversation's title, truncating. Does not exist today - the title is rendered internally by `ListItem`'s `title` prop, so a composed row currently has no addressable title leaf. #2977 adds it so the default row is fully recomposable. Default content: `conversation.title`. During rename mode, `.Title` hosts the inline `<input>` so row geometry stays stable.
One `<span>`: the conversation's title, truncating. The export ships from `veryfront/chat` today: a `<ChatSidebar.Item.Title>` child moves the item's children into the row body, and a sibling `<ChatSidebar.Item.Menu>` stays in the action slot when it is direct or grouped in a fragment. Default content: `conversation.title`. Still proposed under #2977: `children` replacing the whole row, and rename mode hosting the inline `<input>` inside `.Title` so row geometry stays stable.

**Layout:** in-flow text span; give its wrapper `min-w-0`/`flex-1` (or class the span `truncate`) for ellipsis.

Expand Down
6 changes: 3 additions & 3 deletions scripts/lint/ban-chat-antipatterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,12 @@ async function walk(
const FILE_SIZE_CEILINGS: Record<string, number> = {
// Chat preset implementation lives in chat/chat-preset.tsx. This barrel grew
// only for the explicit conversation-persistence contracts exported here.
"src/react/components/chat/chat/index.tsx": 278,
"src/react/components/chat/chat/index.tsx": 279,
// Message.Sources extracted to composition/message-sources.tsx.
"src/react/components/chat/chat/composition/message.tsx": 793,
// Includes the ChatSidebar.Item menu compound (Item.Menu/.Rename/.Delete).
// Includes the ChatSidebar.Item compound (Item.Title/.Menu/.Rename/.Delete).
// Split responsibilities before adding more behavior to this file.
"src/react/components/chat/chat/components/sidebar.tsx": 628,
"src/react/components/chat/chat/components/sidebar.tsx": 685,
// Composer state and native action leaves live in focused sibling modules.
"src/react/components/chat/chat/composition/chat-composer.tsx": 389,
"src/react/components/chat/chat/composition/chat-input-actions.tsx": 159,
Expand Down
4 changes: 3 additions & 1 deletion src/chat/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
UseConversationChatOptions as ComponentsChatOptions,
UseConversationChatResult as ComponentsChatResult,
} from "veryfront/components/chat";
import type { ConversationStorageLimits } from "./index.ts";
import type { ChatSidebarItemTitleProps, ConversationStorageLimits } from "./index.ts";
import type {
UseConversationChatOptions as ReactComponentsChatOptions,
UseConversationChatResult as ReactComponentsChatResult,
Expand All @@ -28,9 +28,11 @@ const _reactComponentsChatOptions: ReactComponentsChatOptions = _componentsChatO
const _componentsChatResult = null as unknown as ComponentsChatResult;
const _reactComponentsChatResult: ReactComponentsChatResult = _componentsChatResult;
const _storageLimits: ConversationStorageLimits = chatModule.CONVERSATION_STORAGE_LIMITS;
const _sidebarTitleProps: ChatSidebarItemTitleProps = { id: "conversation-title" };
void _reactComponentsChatOptions;
void _reactComponentsChatResult;
void _storageLimits;
void _sidebarTitleProps;

const expectedRuntimeExports = [
// Canonical component names.
Expand Down
1 change: 1 addition & 0 deletions src/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export {
type ChatSidebarEmptyProps,
type ChatSidebarGroupProps,
type ChatSidebarItemProps,
type ChatSidebarItemTitleProps,
type ChatSidebarListProps,
type ChatSidebarNewButtonProps,
type ChatSidebarProps,
Expand Down
1 change: 1 addition & 0 deletions src/react/components/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export {
type ChatSidebarEmptyProps,
type ChatSidebarGroupProps,
type ChatSidebarItemProps,
type ChatSidebarItemTitleProps,
type ChatSidebarListProps,
type ChatSidebarNewButtonProps,
type ChatSidebarProps,
Expand Down
135 changes: 135 additions & 0 deletions src/react/components/chat/chat/components/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,141 @@ describe("ChatSidebar.Item — menu compound (E4 acid test)", () => {
});
});

describe("ChatSidebar.Item.Title: composable row label", () => {
it("exposes the Title leaf off the compound", () => {
assert(typeof ChatSidebar.Item.Title === "function", "Item.Title is addressable");
});

it("composes the row label alongside a sibling", () => {
const html = renderToString(
<ChatSidebar.Root
conversations={[summary("x", "Row title", 5000)]}
activeId="x"
onSelect={() => {}}
onDelete={() => {}}
>
<ChatSidebar.List>
<ChatSidebar.Item conversation={summary("x", "Row title", 5000)}>
<ChatSidebar.Item.Title />
<span data-badge="">badge</span>
</ChatSidebar.Item>
</ChatSidebar.List>
</ChatSidebar.Root>,
);
assert(html.includes(">Row title<"), "the Title leaf renders the conversation title");
assert(html.includes("data-badge"), "the sibling badge renders next to the title");
const primaryActionClass = html.match(/<button[^>]*class="([^"]*)"[^>]*>/)?.[1] ?? "";
const titleClass = html.match(/<span[^>]*class="([^"]*)"[^>]*>Row title/)?.[1] ?? "";
assert(
primaryActionClass.includes("flex items-center gap-1"),
"the primary action keeps the composed label and badge on one row",
);
assert(
titleClass.includes("min-w-0 flex-1"),
"the title shrinks before its sibling badge",
);
assertEquals(
html.split(">Row title<").length - 1,
1,
"the composed Title replaces the default title (no duplicate label)",
);
});

it("forwards native span props from the Title leaf", () => {
const html = renderToString(
<ChatSidebar.Root
conversations={[summary("x", "Row title", 5000)]}
activeId="x"
onSelect={() => {}}
onDelete={() => {}}
>
<ChatSidebar.List>
<ChatSidebar.Item conversation={summary("x", "Row title", 5000)}>
<ChatSidebar.Item.Title id="custom-title" data-title="" />
</ChatSidebar.Item>
</ChatSidebar.List>
</ChatSidebar.Root>,
);
assert(html.includes('id="custom-title"'), "Title forwards native span props");
assert(html.includes('data-title=""'), "Title forwards data attributes");
});

it("keeps a composed Menu sibling in the action slot (no default duplicate)", () => {
const html = renderToString(
<ChatSidebar.Root
conversations={[summary("x", "Row title", 5000)]}
activeId="x"
onSelect={() => {}}
onDelete={() => {}}
>
<ChatSidebar.List>
<ChatSidebar.Item conversation={summary("x", "Row title", 5000)}>
<ChatSidebar.Item.Title />
<ChatSidebar.Item.Menu />
</ChatSidebar.Item>
</ChatSidebar.List>
</ChatSidebar.Root>,
);
assertEquals(
html.split("More actions for Row title").length - 1,
1,
"exactly one menu trigger renders, the composed Menu instead of a second default",
);
assert(html.includes(">Row title<"), "the composed Title still renders the label");
});

it("partitions Title and Menu leaves grouped in a fragment", () => {
const html = renderToString(
<ChatSidebar.Root
conversations={[summary("x", "Row title", 5000)]}
activeId="x"
onSelect={() => {}}
onDelete={() => {}}
>
<ChatSidebar.List>
<ChatSidebar.Item conversation={summary("x", "Row title", 5000)}>
{React.createElement(
React.Fragment,
null,
<ChatSidebar.Item.Title />,
<span data-badge="">badge</span>,
<ChatSidebar.Item.Menu />,
)}
</ChatSidebar.Item>
</ChatSidebar.List>
</ChatSidebar.Root>,
);
assertEquals(
html.split("More actions for Row title").length - 1,
1,
"the fragment's Menu fills the action slot without a default duplicate",
);
assert(html.includes(">Row title<"), "the fragment's Title fills the row body");
assert(html.includes("data-badge"), "sibling fragments preserve other body content");
});

it("regression: a childless Item still renders the default title", () => {
const html = renderToString(
<ChatSidebar.Root
conversations={[summary("x", "Row title", 5000)]}
activeId="x"
onSelect={() => {}}
onDelete={() => {}}
>
<ChatSidebar.List>
<ChatSidebar.Item conversation={summary("x", "Row title", 5000)} />
</ChatSidebar.List>
</ChatSidebar.Root>,
);
assert(html.includes(">Row title<"), "the childless row renders the default title");
assertEquals(
html.split(">Row title<").length - 1,
1,
"the default title renders exactly once",
);
});
});

describe("ChatSidebarRenameEditor", () => {
async function runKeyboardCompletion(key: "Enter" | "Escape"): Promise<[number, number]> {
const restoreDom = installDom();
Expand Down
69 changes: 63 additions & 6 deletions src/react/components/chat/chat/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import type {
ChatSidebarItemContextValue,
ChatSidebarItemMenuProps,
ChatSidebarItemProps,
ChatSidebarItemTitleProps,
ChatSidebarListProps,
ChatSidebarNewButtonProps,
ChatSidebarProps,
Expand All @@ -77,6 +78,7 @@ export type {
ChatSidebarItemMenuProps,
ChatSidebarItemProps,
ChatSidebarItemRenderOptions,
ChatSidebarItemTitleProps,
ChatSidebarListProps,
ChatSidebarNewButtonProps,
ChatSidebarProps,
Expand Down Expand Up @@ -276,10 +278,45 @@ const [ChatSidebarItemContext, useChatSidebarItemStrict] = createStrictContext<
*/
export const useChatSidebarItem = useChatSidebarItemStrict;

/**
* The row's label: the conversation title (or custom children). Use it inside
* an `<ChatSidebar.Item>` to compose the row body and skip its default title.
*/
export function ChatSidebarItemTitle({
children,
className,
ref,
...props
}: ChatSidebarItemTitleProps): React.ReactElement {
const { conversation } = useChatSidebarItem();
return (
<span
{...props}
ref={ref}
className={cn("block min-w-0 flex-1 truncate text-[13px] leading-snug", className)}
>
{children ?? conversation.title}
</span>
);
}
ChatSidebarItemTitle.displayName = "ChatSidebar.Item.Title";

/** Flatten transparent fragments so slot detection and extraction see the same leaves. */
function flattenItemParts(children: React.ReactNode): React.ReactNode[] {
return React.Children.toArray(children).flatMap((child) => {
if (!React.isValidElement(child) || child.type !== React.Fragment) return child;
const props = child.props as { children?: React.ReactNode };
return flattenItemParts(props.children);
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
});
}

/**
* A single conversation row — select on click, rename/delete via a "…" menu.
* The menu is a composable compound: pass a `<ChatSidebar.Item.Menu>` child to
* add or reorder entries without re-implementing the row.
* add or reorder entries without re-implementing the row. Children containing a
* `<ChatSidebar.Item.Title>` compose the row's label body instead. A sibling
* `<ChatSidebar.Item.Menu>`, including one grouped in a fragment, fills the
* action slot.
*/
export function ChatSidebarItem({
conversation,
Expand Down Expand Up @@ -334,17 +371,35 @@ export function ChatSidebarItem({
);
}

// A Title child moves children into the row body; otherwise they compose the action slot.
// Transparent fragments keep title and menu partitioning consistent without duplication.
const parts = flattenItemParts(children);
const composesTitle = parts.some(
(part) => React.isValidElement(part) && part.type === ChatSidebarItemTitle,
);
const menuParts = parts.filter(
(part) => React.isValidElement(part) && part.type === ChatSidebarItemMenu,
);
const bodyParts = parts.filter((part) => !menuParts.includes(part));

return (
<ChatSidebarItemContext.Provider value={itemContext}>
<ListItem
ref={ref}
title={conversation.title}
title={composesTitle ? undefined : conversation.title}
active={isActive || menuOpen}
className={className}
onActivate={() => onSelect(conversation.id)}
primaryActionProps={{ "aria-current": isActive ? "page" : undefined }}
action={children ?? <ChatSidebarItemMenu />}
/>
primaryActionProps={{
"aria-current": isActive ? "page" : undefined,
className: composesTitle ? "flex items-center gap-1" : undefined,
}}
action={composesTitle
? (menuParts.length > 0 ? menuParts : <ChatSidebarItemMenu />)
: children ?? <ChatSidebarItemMenu />}
>
{composesTitle ? bodyParts : undefined}
</ListItem>
</ChatSidebarItemContext.Provider>
);
}
Expand Down Expand Up @@ -591,8 +646,9 @@ function ChatSidebarBase(props: ChatSidebarProps): React.ReactElement | null {
}
ChatSidebarBase.displayName = "ChatSidebar";

/** `ChatSidebar.Item` compound — the row plus its composable menu leaves. */
/** `ChatSidebar.Item` compound: the row plus its composable label and menu leaves. */
export type ChatSidebarItemComponent = typeof ChatSidebarItem & {
Title: typeof ChatSidebarItemTitle;
Menu: typeof ChatSidebarItemMenu;
Rename: typeof ChatSidebarItemRename;
Delete: typeof ChatSidebarItemDelete;
Expand All @@ -601,6 +657,7 @@ export type ChatSidebarItemComponent = typeof ChatSidebarItem & {
const ChatSidebarItemCompound: ChatSidebarItemComponent = Object.assign(
ChatSidebarItem,
{
Title: ChatSidebarItemTitle,
Menu: ChatSidebarItemMenu,
Rename: ChatSidebarItemRename,
Delete: ChatSidebarItemDelete,
Expand Down
14 changes: 13 additions & 1 deletion src/react/components/chat/chat/components/sidebar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,25 @@ export interface ChatSidebarItemProps {
className?: string;
/**
* Compose the row's action slot, typically a `<ChatSidebar.Item.Menu>`.
* Omit for the default `…` rename/delete menu.
* When the children include a `<ChatSidebar.Item.Title>`, they compose the
* row's label body instead; a sibling `<ChatSidebar.Item.Menu>` still fills
* the action slot when it is direct or grouped in a fragment (the default
* `…` menu is used when none is given). Omit for the default title + `…`
* rename/delete menu.
*/
children?: React.ReactNode;
/** React 19: ref is attached to the current row root in display and rename modes. */
ref?: React.Ref<HTMLDivElement>;
}

/** Props for {@link ChatSidebarItemTitle}. */
export interface ChatSidebarItemTitleProps extends React.HTMLAttributes<HTMLSpanElement> {
/** Override the label. Defaults to the conversation title. */
children?: React.ReactNode;
/** React 19: ref is a regular prop. */
ref?: React.Ref<HTMLSpanElement>;
}

/** Props for {@link ChatSidebarItemMenu}. */
export interface ChatSidebarItemMenuProps {
/** Override the trigger glyph. */
Expand Down
1 change: 1 addition & 0 deletions src/react/components/chat/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export {
type ChatSidebarItemMenuProps,
type ChatSidebarItemProps,
type ChatSidebarItemRenderOptions,
type ChatSidebarItemTitleProps,
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
type ChatSidebarListProps,
type ChatSidebarNewButtonProps,
type ChatSidebarProps,
Expand Down
Loading