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
249 changes: 249 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,255 @@ 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("preserves keyed-fragment identity for stateful title siblings", async () => {
const restoreDom = installDom();
let mounts = 0;
function StatefulBadge(): React.ReactElement {
const [mountId] = React.useState(() => ++mounts);
return <span data-mount-id={mountId}>badge</span>;
}

try {
const root = createRoot(document.getElementById("root")!);
const renderItem = (fragmentKey: string) => (
<ChatSidebar.Root
conversations={[summary("x", "Row title", 5000)]}
activeId="x"
onSelect={() => {}}
onDelete={() => {}}
>
<ChatSidebar.List>
<ChatSidebar.Item conversation={summary("x", "Row title", 5000)}>
<React.Fragment key={fragmentKey}>
<ChatSidebar.Item.Title />
<StatefulBadge />
</React.Fragment>
</ChatSidebar.Item>
</ChatSidebar.List>
</ChatSidebar.Root>
);

flushSync(() => root.render(renderItem("first")));
assertEquals(document.querySelector("[data-mount-id]")?.getAttribute("data-mount-id"), "1");

flushSync(() => root.render(renderItem("second")));
assertEquals(
document.querySelector("[data-mount-id]")?.getAttribute("data-mount-id"),
"2",
"changing the parent fragment key remounts its stateful descendants",
);

await unmountReactRoot(root);
await settle();
} finally {
restoreDom();
}
});

it("keeps state with fragment paths that contain key separators", async () => {
const restoreDom = installDom();
let mounts = 0;
function StatefulBadge({ label }: { label: string }): React.ReactElement {
const [mountId] = React.useState(() => ++mounts);
return <span data-badge={label} data-mount-id={mountId}>{label}</span>;
}

try {
const root = createRoot(document.getElementById("root")!);
const renderItem = (reverse: boolean) => {
const direct = (
<React.Fragment key="a/.0">
<StatefulBadge label="direct" />
</React.Fragment>
);
const nested = (
<React.Fragment key="a">
<React.Fragment>
<StatefulBadge label="nested" />
</React.Fragment>
</React.Fragment>
);
return (
<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 />
{reverse ? [nested, direct] : [direct, nested]}
</ChatSidebar.Item>
</ChatSidebar.List>
</ChatSidebar.Root>
);
};

flushSync(() => root.render(renderItem(false)));
assertEquals(
document.querySelector('[data-badge="direct"]')?.getAttribute("data-mount-id"),
"1",
);
assertEquals(
document.querySelector('[data-badge="nested"]')?.getAttribute("data-mount-id"),
"2",
);

flushSync(() => root.render(renderItem(true)));
assertEquals(
document.querySelector('[data-badge="direct"]')?.getAttribute("data-mount-id"),
"1",
"state follows the direct fragment after reordering",
);
assertEquals(
document.querySelector('[data-badge="nested"]')?.getAttribute("data-mount-id"),
"2",
"state follows the nested fragment after reordering",
);

await unmountReactRoot(root);
await settle();
} finally {
restoreDom();
}
});

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
Loading