diff --git a/.github/workflows/javascript-build.yml b/.github/workflows/javascript-build.yml
index 88753f32..74a52764 100644
--- a/.github/workflows/javascript-build.yml
+++ b/.github/workflows/javascript-build.yml
@@ -701,8 +701,9 @@ jobs:
/tmp/cratis-components.tgz \
"@cratis/arc@${{ matrix.arc-version }}" \
"@cratis/arc.react@${{ matrix.arc-version }}" \
- @cratis/fundamentals react@19 react-dom@19 \
- reflect-metadata tsyringe
+ @cratis/fundamentals react@19 react-dom@19 reflect-metadata
+ cp "$GITHUB_WORKSPACE/Source/scripts/lib/peer-absent-consumer-probe.mjs" verify-peers.mjs
+ node verify-peers.mjs
node --input-type=module - <<'NODE'
const commandDialog = await import('@cratis/components/CommandDialog');
const dataPage = await import('@cratis/components/DataPage');
@@ -777,6 +778,8 @@ jobs:
JSON
cp Source/scripts/lib/package-manager-consumer-probe.mjs \
/tmp/components-consumer/verify.mjs
+ cp Source/scripts/lib/peer-absent-consumer-probe.mjs \
+ /tmp/components-consumer/verify-peers.mjs
- name: Verify npm consumer
if: matrix.manager == 'npm'
@@ -788,9 +791,9 @@ jobs:
/tmp/cratis-components.tgz \
@cratis/arc@22.16.0 @cratis/arc.react@22.16.0 \
@cratis/fundamentals@7.19.2 @types/react@19.2.18 \
- react@19.2.8 react-dom@19.2.8 \
- reflect-metadata@0.2.2 tsyringe@4.10.0 "${PIXI[@]}"
+ react@19.2.8 react-dom@19.2.8 reflect-metadata@0.2.2 "${PIXI[@]}"
node verify.mjs "${{ matrix.pixi }}"
+ node verify-peers.mjs
- name: Verify pnpm consumer
if: matrix.manager == 'pnpm'
@@ -803,9 +806,9 @@ jobs:
/tmp/cratis-components.tgz \
@cratis/arc@22.16.0 @cratis/arc.react@22.16.0 \
@cratis/fundamentals@7.19.2 @types/react@19.2.18 \
- react@19.2.8 react-dom@19.2.8 \
- reflect-metadata@0.2.2 tsyringe@4.10.0 "${PIXI[@]}"
+ react@19.2.8 react-dom@19.2.8 reflect-metadata@0.2.2 "${PIXI[@]}"
pnpm exec node verify.mjs "${{ matrix.pixi }}"
+ pnpm exec node verify-peers.mjs
- name: Verify Yarn PnP consumer
if: matrix.manager == 'yarn-pnp'
@@ -822,8 +825,9 @@ jobs:
@cratis/arc@22.16.0 @cratis/arc.react@22.16.0 \
@cratis/fundamentals@7.19.2 @types/react@19.2.18 \
react@19.2.8 react-dom@19.2.8 \
- reflect-metadata@0.2.2 tsyringe@4.10.0 rxjs@7.8.2 "${PIXI[@]}"
+ reflect-metadata@0.2.2 rxjs@7.8.2 "${PIXI[@]}"
yarn node verify.mjs "${{ matrix.pixi }}"
+ yarn node verify-peers.mjs
verify-renderer-adapters:
name: Renderer adapter (${{ matrix.adapter }}, ${{ matrix.boundary }}, ${{ matrix.manager }})
diff --git a/Documentation/Chat/index.md b/Documentation/Chat/index.md
index 91e1d614..a52c3898 100644
--- a/Documentation/Chat/index.md
+++ b/Documentation/Chat/index.md
@@ -20,7 +20,7 @@ Everything about _data_ stays with your application:
- `@`-mentions from a list you hold or a provider callback you resolve, rendered distinctly in message bodies
- Emoji picker in the composer, with a quick row of recently used emoji
- Host-supplied render callbacks for avatars and display names — messages carry only the author id
-- Extensible per-message actions shown on hover or keyboard focus
+- Extensible per-message and per-topic actions shown on hover or keyboard focus
- Every color from the `--cratis-*` token seam, so the chat follows your theme
## Components
@@ -97,7 +97,8 @@ export const Workspace = () => {
| `onRequestTopicName` / `isTopicUnnamed` | callbacks | — | The host-side naming contract. |
| `selectedTopicId` / `onTopicSelected` | `ChatIdentifier \| null`, callback | Internal | Owns or observes the open topic. |
| `authorOf`, `renderAvatar`, `renderAuthorName`, `buildAvatarUrl` | callbacks | — | Author resolution and rendering. Without `authorOf`, the id is shown as the name. |
-| `actions`, `quickReply` | `ChatMessageAction[]`, `boolean` | —, `true` | See [Message actions](./message-actions.md). |
+| `actions`, `quickReply` | `ChatMessageAction[]`, `boolean` | —, `true` | Message actions and quick reply; see [Message actions](./message-actions.md). |
+| `topicActions` | `ChatTopicAction[]` | — | Actions beside available topics; see [Topic actions](./topic-actions.md). |
| `mentionCandidates` / `resolveMentionCandidates` | array or callback | — | See [Mentions and emoji](./mentions-and-emoji.md). Omit both to turn mentions off. |
| `typingAuthors` | `ChatTypingAuthor[]` | `[]` | Who the conversation is waiting on. |
| `autoFocus` | `boolean` | `false` | Focuses the composer when a conversation mounts. |
@@ -218,4 +219,5 @@ Import `@cratis/components/styles` (or, with per-area stylesheets, `@cratis/comp
- [Topics and naming](./topics-and-naming.md) — the topic lifecycle and the host-side auto-naming contract
- [Mentions and emoji](./mentions-and-emoji.md) — candidate providers, how mentions travel and render
- [Message actions](./message-actions.md) — offering your own actions on messages
+- [Topic actions](./topic-actions.md) — offering your own actions on topics
- [Observable queries](./observable-queries.md) — the optional Arc-aware wrapper
diff --git a/Documentation/Chat/toc.yml b/Documentation/Chat/toc.yml
index 1950d7f5..d75d6d53 100644
--- a/Documentation/Chat/toc.yml
+++ b/Documentation/Chat/toc.yml
@@ -6,5 +6,7 @@
href: mentions-and-emoji.md
- name: Message actions
href: message-actions.md
+- name: Topic actions
+ href: topic-actions.md
- name: Observable queries
href: observable-queries.md
diff --git a/Documentation/Chat/topic-actions.md b/Documentation/Chat/topic-actions.md
new file mode 100644
index 00000000..c64ee66b
--- /dev/null
+++ b/Documentation/Chat/topic-actions.md
@@ -0,0 +1,41 @@
+---
+title: Topic actions
+description: Offer host-owned actions next to each topic without opening its conversation.
+---
+
+Pass `topicActions` to `ChatSidebar` or the standalone `ChatTopicList` when a person needs to rename, archive, or otherwise act on a topic without opening it. Unlike the sidebar's `actions` prop, which applies to **messages**, `topicActions` applies to topics only.
+
+## Descriptor
+
+| Field | Type | Required | Behavior |
+| --- | --- | --- | --- |
+| `id` | `string` | Yes | Unique rendering key among the actions. |
+| `label` | `string` | Yes | Tooltip and accessible name (combined with the topic's name). Shown as button text if no icon is supplied. |
+| `icon` | `string \| ReactNode` | No | An icon-font CSS class name or a ready element. |
+| `isAvailable` | `(topic: TTopic) => boolean` | No | Return `false` to omit the action for a topic; omitted means available on every topic. |
+| `onInvoke` | `(topic: TTopic) => void` | Yes | Receives the full host topic when the control is activated. |
+
+The buttons sit beside, not inside, the button that opens the conversation. They appear on hover or keyboard focus. Tab reaches each available control, and activating one does not open the topic.
+
+## Offer an action
+
+This excerpt assumes `sidebarProps` supplies `open`, `onClose`, `topics`, `messages`, and `onSendMessage` as in [basic usage](./index.md#basic-usage). The handlers belong to your application.
+
+```tsx
+import { ChatSidebar } from '@cratis/components/Chat';
+
+ Boolean(topic.name?.trim()),
+ onInvoke: topic => renameTopic(topic.id),
+ },
+ ]}
+/>
+```
+
+The same `topicActions` prop works on `ChatTopicList`. Omit it to leave the existing topic markup and opening behavior unchanged.
diff --git a/Documentation/Chat/topics-and-naming.md b/Documentation/Chat/topics-and-naming.md
index 1207003b..07909a10 100644
--- a/Documentation/Chat/topics-and-naming.md
+++ b/Documentation/Chat/topics-and-naming.md
@@ -22,6 +22,18 @@ interface ChatTopic {
The list orders topics by `lastActivity` (falling back to `started`) itself — hand it topics in any order.
+## ChatTopicList props
+
+| Prop | Type | Default | Behavior |
+| --- | --- | --- | --- |
+| `topics`, `onOpen` | `TTopic[]`, `(topic: TTopic) => void` | Required | Topics and the callback for opening one. |
+| `onStart` | `() => void` | — | Shows the new-topic button when supplied. |
+| `topicActions` | `ChatTopicAction[]` | — | Host actions beside topics where they are available. See [Topic actions](./topic-actions.md). |
+| `status` | `ChatStatus` | `Ready` | List query display state. |
+| `authorOf`, `renderAvatar`, `buildAvatarUrl` | callbacks | — | Resolve and render the topic starter. |
+| `isTopicUnnamed` | `(topic: TTopic) => boolean` | Blank name | Decides when to show the pending placeholder. |
+| `labels`, `className` | `ChatTopicListLabels`, `string` | — | Label overrides and root class name. |
+
## Starting a topic
`onStartTopic` raises the intent; creating the topic is the application's business. Answer with the new topic's id — directly or through a promise — and the sidebar opens it, ready for the first message:
diff --git a/Documentation/getting-started.mdx b/Documentation/getting-started.mdx
index 6d877fea..45dcf48b 100644
--- a/Documentation/getting-started.mdx
+++ b/Documentation/getting-started.mdx
@@ -27,19 +27,19 @@ The examples use the proxies from the Arc tutorial: `RegisterAuthor` has a requi
React Aria, the internationalized date implementation, and React Icons are internal dependencies. The package does not depend on PrimeReact or any other UI kit or theme runtime, and you don't configure an icon package. The optional MUI and PrimeReact integrations are separate [renderer adapter](/components/renderers/) packages.
- The package declares React, Arc, Fundamentals, `reflect-metadata`, and `tsyringe` as peers. Keep `@cratis/arc` and `@cratis/arc.react` on the same version your generated proxies were produced with. A strict installer can install every peer explicitly (replace the Arc version with the one your backend uses):
+ The package requires React, Arc, Fundamentals, and `reflect-metadata` as peers. `tsyringe` is an optional peer: Components never imports it, and Arc React installs it as its own dependency. Keep `@cratis/arc` and `@cratis/arc.react` on the same version your generated proxies were produced with. A strict installer can install the required peers explicitly (replace the Arc version with the one your backend uses):
```bash title="Install explicit peers"
ARC_VERSION=22.16.0
npm install @cratis/components@^4 \
"@cratis/arc@$ARC_VERSION" "@cratis/arc.react@$ARC_VERSION" \
@cratis/fundamentals@^7.19.2 react@^19 react-dom@^19 \
- reflect-metadata@0.2.2 tsyringe@4.10.0
+ reflect-metadata@0.2.2
```
Use Arc 22.16.0 or newer if you plan to use `AutoCommandForm`'s explicit field binding.
- `pixi.js@^8.20.0` is an additional **optional** peer, needed only if you use `Canvas` or `PivotViewer`. Every other component needs nothing beyond the peers above; install Pixi later, when you reach a spatial workspace or card-grid screen. See [Choosing a component](/components/choosing-a-component/#spatial-workspaces).
+ `pixi.js@^8.20.0` is another **optional** peer, needed only if you use `Canvas` or `PivotViewer`. Every other component needs nothing beyond the required peers above; install Pixi later, when you reach a spatial workspace or card-grid screen. See [Choosing a component](/components/choosing-a-component/#spatial-workspaces).
2. **Import the stylesheets once**, at your application entry point, after `reflect-metadata`:
diff --git a/README.md b/README.md
index 4952b10e..b519312d 100644
--- a/README.md
+++ b/README.md
@@ -58,8 +58,8 @@ npm install @cratis/components@^4
Components 4 needs React 19 and Arc. Its peers are `react` and `react-dom` `^19.0.0`,
`@cratis/arc` and `@cratis/arc.react` `>=20.3.1 <23`, `@cratis/fundamentals` `^7.10.3`,
-`reflect-metadata` `0.2.2`, and `tsyringe` `4.10.0`; `pixi.js` `^8.20.0` is an optional peer
-needed only by `Canvas` and `PivotViewer`. This repository builds and tests against Arc 22.16.0.
+and `reflect-metadata` `0.2.2`. `tsyringe` `4.10.0` is an optional peer that Arc React already
+installs, and `pixi.js` `^8.20.0` is an optional peer needed only by `Canvas` and `PivotViewer`. This repository builds and tests against Arc 22.16.0.
The package does not depend on PrimeReact; the optional MUI and PrimeReact renderer adapters are
separate packages.
diff --git a/Source/Chat/ChatSidebar.stories.tsx b/Source/Chat/ChatSidebar.stories.tsx
index b62de131..38110b8b 100644
--- a/Source/Chat/ChatSidebar.stories.tsx
+++ b/Source/Chat/ChatSidebar.stories.tsx
@@ -4,7 +4,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from 'storybook/test';
import { useRef, useState } from 'react';
-import { FaCopy } from 'react-icons/fa6';
+import { FaCopy, FaPen } from 'react-icons/fa6';
import { ChatAuthorKind } from './Kit/ChatAuthorKind';
import type { ChatAuthor } from './ChatAuthor';
import type { ChatIdentifier } from './ChatIdentifier';
@@ -72,6 +72,7 @@ export const Playground: Story = {
onStartTopic: fn(),
onRequestTopicName: fn(),
onTopicSelected: fn(),
+ topicActions: [{ id: 'rename', label: 'Rename', icon: , onInvoke: fn() }],
authorOf,
mentionCandidates,
},
diff --git a/Source/Chat/ChatSidebar.tsx b/Source/Chat/ChatSidebar.tsx
index 96b9727a..3f3ae177 100644
--- a/Source/Chat/ChatSidebar.tsx
+++ b/Source/Chat/ChatSidebar.tsx
@@ -11,6 +11,7 @@ import { sameChatIdentifier, type ChatIdentifier } from './ChatIdentifier';
import type { ChatMention } from './ChatMention';
import type { ChatMessage } from './ChatMessage';
import type { ChatTopic } from './ChatTopic';
+import type { ChatTopicAction } from './ChatTopicAction';
import type { ChatStatus } from './ChatStatus';
import type { ChatTopicListLabels } from './ChatTopicList';
import { ChatTopicList } from './ChatTopicList';
@@ -107,6 +108,9 @@ export interface ChatSidebarProps<
*/
messages: TMessage[];
+ /** The host's actions to offer on each topic where they are available. The inherited `actions` prop applies only to messages. */
+ topicActions?: ChatTopicAction[];
+
/** Topic-list query display state. Defaults to ready when omitted. */
topicsStatus?: ChatStatus;
@@ -225,6 +229,7 @@ export const ChatSidebar = <
onClose,
topics,
messages,
+ topicActions,
topicsStatus,
messagesStatus,
selectedTopicId,
@@ -399,6 +404,7 @@ export const ChatSidebar = <
{openTopicId === undefined ? (
topics={topics}
+ topicActions={topicActions}
status={topicsStatus}
onOpen={(topic) => select(topic.id, topic)}
onStart={
diff --git a/Source/Chat/ChatTopicAction.ts b/Source/Chat/ChatTopicAction.ts
new file mode 100644
index 00000000..799df2a7
--- /dev/null
+++ b/Source/Chat/ChatTopicAction.ts
@@ -0,0 +1,30 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { ReactNode } from 'react';
+import type { ChatTopic } from './ChatTopic';
+
+/** An action a host offers on topics. The library ships no topic actions of its own.
+ * @typeParam TTopic The host's topic type, so {@link onInvoke} receives the full topic back.
+ */
+export interface ChatTopicAction {
+ /** Identifies the action among its siblings — used as the rendering key. */
+ id: string;
+
+ /** The label, used in the tooltip and accessible name alongside the topic's name. */
+ label: string;
+
+ /** An icon element or a CSS class name for the host's icon font. Omit to show the label. */
+ icon?: string | ReactNode;
+
+ /** Decides which topics the action is offered on. Omit to offer it on all topics.
+ * @param topic The topic the action would apply to.
+ * @returns True when the action should be offered.
+ */
+ isAvailable?: (topic: TTopic) => boolean;
+
+ /** Invoked when the action is picked on a topic.
+ * @param topic The topic it was picked on.
+ */
+ onInvoke: (topic: TTopic) => void;
+}
diff --git a/Source/Chat/ChatTopicActions.tsx b/Source/Chat/ChatTopicActions.tsx
new file mode 100644
index 00000000..0a3c4d3d
--- /dev/null
+++ b/Source/Chat/ChatTopicActions.tsx
@@ -0,0 +1,55 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { FocusEventHandler } from 'react';
+import type { ChatTopic } from './ChatTopic';
+import type { ChatTopicAction } from './ChatTopicAction';
+
+/**
+ * Props for {@link ChatTopicActions}.
+ * @typeParam TTopic The topic type the list renders.
+ */
+export interface ChatTopicActionsProps {
+ /** The topic the actions act on. */
+ topic: TTopic;
+ /** The topic's visible name, used to give each action an accessible name for its topic. */
+ topicName: string | undefined;
+ /** The actions available for this topic. */
+ actions: ChatTopicAction[];
+ /** Called when an action button gains focus. */
+ onActionFocus: FocusEventHandler;
+ /** Called when an action button loses focus. */
+ onActionBlur: FocusEventHandler;
+}
+
+/**
+ * The action buttons shown beside a topic's opening button. Reuses the message action overlay,
+ * which keyboard focus reveals.
+ */
+export const ChatTopicActions = ({
+ topic,
+ topicName,
+ actions,
+ onActionFocus,
+ onActionBlur,
+}: ChatTopicActionsProps) => (
+
+ {actions.map((action) => (
+
+ ))}
+
+);
diff --git a/Source/Chat/ChatTopicList.css b/Source/Chat/ChatTopicList.css
index 053424f0..dfb3c107 100644
--- a/Source/Chat/ChatTopicList.css
+++ b/Source/Chat/ChatTopicList.css
@@ -69,10 +69,26 @@
transition: background 0.12s;
}
-.cratis-chat-topics__topic:hover {
+.cratis-chat-topics__topic:hover,
+.cratis-chat-topics__row:hover .cratis-chat-topics__topic {
background: var(--cratis-surface-hover);
}
+/* Topic actions reuse the message action overlay, centered on the row instead of hanging above it. */
+.cratis-chat-topics__row {
+ position: relative;
+}
+
+.cratis-chat-topics__row .cratis-chat-message__actions {
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.cratis-chat-topics__row:hover .cratis-chat-message__actions {
+ opacity: 1;
+ pointer-events: auto;
+}
+
.cratis-chat-topics__details {
flex: 1;
min-width: 0;
diff --git a/Source/Chat/ChatTopicList.stories.tsx b/Source/Chat/ChatTopicList.stories.tsx
index d9e42edf..4671799a 100644
--- a/Source/Chat/ChatTopicList.stories.tsx
+++ b/Source/Chat/ChatTopicList.stories.tsx
@@ -3,6 +3,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from 'storybook/test';
+import { FaPen, FaBoxArchive } from 'react-icons/fa6';
import { ChatAuthorKind } from './Kit/ChatAuthorKind';
import type { ChatAuthor } from './ChatAuthor';
import type { ChatIdentifier } from './ChatIdentifier';
@@ -74,6 +75,26 @@ export const Playground: Story = {
),
};
+/** Hover a topic, or Tab from its open button, to see the host's available actions. */
+export const WithActions: Story = {
+ args: {
+ topics: [
+ { id: 'topic-1', name: 'Example topic' },
+ { id: 'topic-2', name: 'Pinned topic', metadata: { pinned: true } },
+ ],
+ onOpen: fn(),
+ topicActions: [
+ { id: 'rename', label: 'Rename', icon: , onInvoke: fn() },
+ { id: 'archive', label: 'Archive', icon: , isAvailable: (topic) => topic.metadata?.pinned !== true, onInvoke: fn() },
+ ],
+ },
+ render: (args) => (
+
+
+
+ ),
+};
+
/** Nothing to pick from yet — only the empty message and the way to start the first topic. */
export const Empty: Story = {
args: {
diff --git a/Source/Chat/ChatTopicList.tsx b/Source/Chat/ChatTopicList.tsx
index 3bfb24bf..b6f94856 100644
--- a/Source/Chat/ChatTopicList.tsx
+++ b/Source/Chat/ChatTopicList.tsx
@@ -1,15 +1,18 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-import type { ReactNode } from 'react';
+import { useRef, type ReactNode } from 'react';
import { PersonAvatarCircle, type BuildAvatarUrlParams } from './Kit/Avatar';
import { ChatAuthorKind } from './Kit/ChatAuthorKind';
import type { ChatAuthor } from './ChatAuthor';
import { chatIdentifierString, type ChatIdentifier } from './ChatIdentifier';
import type { ChatTopic } from './ChatTopic';
+import type { ChatTopicAction } from './ChatTopicAction';
+import { ChatTopicActions } from './ChatTopicActions';
import { ChatStatus } from './ChatStatus';
import { isTopicUnnamed as defaultIsTopicUnnamed } from './isTopicUnnamed';
import { relativeTimestamp, type RelativeTimestampLabels } from './relativeTimestamp';
+import { useTopicActionFocusRecovery } from './useTopicActionFocusRecovery';
import { topicsByActivity } from './topicsByActivity';
const PlusIcon = () => (
@@ -112,6 +115,9 @@ export interface ChatTopicListProps {
/** Overrides for every label rendered. Unset fields fall back to literal English defaults. */
labels?: ChatTopicListLabels;
+ /** The host's actions to offer on each topic where they are available. */
+ topicActions?: ChatTopicAction[];
+
/** Additional class name for the list's root element. */
className?: string;
}
@@ -132,9 +138,16 @@ export const ChatTopicList = ({
isTopicUnnamed,
buildAvatarUrl,
labels,
+ topicActions,
className,
}: ChatTopicListProps) => {
const unnamed = isTopicUnnamed ?? defaultIsTopicUnnamed;
+ const listRef = useRef(null);
+ const orderedTopics = status === ChatStatus.Unauthorized ? [] : topicsByActivity(topics);
+ const { onActionFocus, onActionBlur } = useTopicActionFocusRecovery(
+ listRef,
+ orderedTopics.map((topic) => chatIdentifierString(topic.id)),
+ );
const authorFor = (authorId: ChatIdentifier): ChatAuthor =>
authorOf?.(authorId) ?? {
@@ -168,67 +181,87 @@ export const ChatTopicList = ({
)}