Skip to content

ChatTopicList offers no per-topic actions #206

Description

@einari

What is expected

ChatTopicList renders each topic as a row that can be opened, but exposes no way to offer an action on a topic — archive, rename, pin, delete. ChatConversation already has this shape for messages via its actions prop, so the pattern exists; the topic list simply has no equivalent.

What happens instead

ChatTopicListProps has topics, onOpen, onStart, authorOf, renderAvatar, isTopicUnnamed, buildAvatarUrl, labels and className. There is no actions (or topicActions) prop, and no render hook for trailing content on a row, so a consumer cannot attach anything to a topic without reimplementing the whole list.

Why it matters

Archiving a conversation is a normal thing to want in a chat, and it is not reachable today: a consumer either goes without, or stops using ChatTopicList and rebuilds the list — losing the avatars, relative timestamps, unnamed-topic handling and empty state that are the reason to use it.

A representative host needs exactly this for archiving a chat topic. The backend behavior is straightforward, but there is nowhere to put the affordance.

Suggested direction

Mirror what ChatConversation already does for messages — an optional actions prop of per-topic actions (label, icon, onInvoke(topic), optional isAvailable(topic)), rendered as a row-level menu. A render hook for trailing row content would work equally well; the decision is yours.

Activity

  1. cratis-direct commented on Aug 29, 2026

    @cratis-direct

    Investigation

    Investigation summary — #206

    This is a feature request, but I confirmed the gap empirically rather than by reading alone. I wrote a throwaway spec at Source/Chat/for_ChatTopicList/when_topic_actions_are_offered.ts that renders ChatTopicList with an actions prop and asserts an action button appears — it failed (expected false to be true, no [aria-label="Archive"] in the DOM). I deleted it afterwards; git status is clean and no source was changed.

    The same throwaway spec also pinned down the one real implementation hazard: Source/Chat/ChatTopicList.tsx:133 renders each row as a <button role='listitem'>, so action buttons cannot be nested inside it. React 19 emits: In HTML, <button> cannot be a descendant of <button>. This will cause a hydration error. The row markup has to change — that is the non-obvious part of this issue.

    Baseline before any change: npx vitest run Chat → 30 files, 145 tests, all passing.

    SUGGESTED-MODEL: sonnet

    Plan — per-topic actions on ChatTopicList

    Settled by this investigation (do not re-litigate)

    • The gap is real and reproduced. No actions/topicActions prop exists on ChatTopicListProps (ChatTopicList.tsx:38-91), and nothing renders trailing row content.
    • The row must stop being a <button> wrapper. Verified React 19 rejects button-in-button. Restructure to <div role='listitem' className='cratis-chat-topics__row'> containing the existing <button className='cratis-chat-topics__topic'> plus a sibling actions overlay. Keep the class cratis-chat-topics__topic on the inner button — for_ChatTopicList/when_a_topic_is_picked.ts:56 and for_ChatSidebar/when_moving_between_topics_and_conversation.ts:51 click on that selector, and __name, __name--pending, __started-by, __start are asserted too. No spec depends on role='listitem', so moving the role to the wrapper is free.
    • ChatSidebar cannot call the prop actions. ChatSidebarProps extends ChatConversationProps omitting only messages | onSendMessage | labels | className (ChatSidebar.tsx:46-47), so actions is already taken by message actions and forwarded via ...conversation. The sidebar's prop must be topicActions, forwarded explicitly to <ChatTopicList> at ChatSidebar.tsx:260-268.
    • ChatSidebarForObservableQueries needs no change — it spreads ...sidebar straight through.
    • The precedent to mirror is ChatMessageAction (Source/Chat/ChatMessageAction.ts) + its render at ChatConversation.tsx:223,255-278 + its CSS at ChatConversation.css:92-131 (absolute overlay, opacity/pointer-events toggle rather than display:none, so a focused button stays reachable).

    Implementation steps

    1. Source/Chat/ChatTopicAction.ts (new) — ChatTopicAction<TTopic extends ChatTopic = ChatTopic> with id, label, icon: string | ReactNode, isAvailable?: (topic) => boolean, onInvoke: (topic) => void. A verbatim structural mirror of ChatMessageAction, full XML-ish TSDoc on every member, license header. (Deliberately not refactoring both onto a shared ChatAction<T> — that churns a published type name for no consumer benefit.)

    2. ChatTopicList.tsx

      • Add actions?: ChatTopicAction<TTopic>[] to ChatTopicListProps, documented as "the host's own actions, offered on every topic each is available for".
      • Per row: const availableActions = (actions ?? []).filter(a => a.isAvailable?.(topic) ?? true);
      • Restructure the row to wrapper-div + inner button + conditional <div className='cratis-chat-topics__actions'> of <button className='cratis-chat-topics__action' title/aria-label={action.label} onClick={() => action.onInvoke(topic)}>, icon rendered typeof icon === 'string' ? <i className={icon} aria-hidden/> : icon — identical to ChatConversation.tsx:267-278.
      • Because the actions container is a sibling of the row button, no stopPropagation is needed: an action click never reaches onOpen. Add a spec for that anyway (below).
    3. ChatTopicList.css — three things, and the first two are easy to get wrong:

      • Move the hover highlight from .cratis-chat-topics__topic:hover to .cratis-chat-topics__row:hover .cratis-chat-topics__topic. The overlay is not a descendant of the row button, so with the current rule the highlight would drop out the moment the pointer moves onto an action.
      • Reveal with .cratis-chat-topics__row:hover .cratis-chat-topics__actions, .cratis-chat-topics__actions:focus-within { opacity: 1; pointer-events: auto; } — mirroring ChatConversation.css:108-112, including the pointer-events: none in the hidden state (an opacity: 0 overlay still swallows clicks otherwise).
      • .cratis-chat-topics__row { position: relative; }, overlay position: absolute; right: 0.375rem; top: 50%; transform: translateY(-50%); with --cratis-surface-overlay background, so it reads cleanly where it covers the activity timestamp. All colors from --cratis-* tokens, as the file's own header comment requires.
    4. ChatSidebar.tsx — add topicActions?: ChatTopicAction<TTopic>[] to ChatSidebarProps, destructure it, forward as actions={topicActions}.

    5. Source/Chat/index.ts — export type { ChatTopicAction } from './ChatTopicAction'; (alphabetically after ChatTopic). No package.json exports-map change: @cratis/components/Chat already exists.

    6. Specs — Source/Chat/for_ChatTopicList/when_a_topic_action_is_invoked.ts, modeled line-for-line on for_ChatConversation/when_a_message_action_is_invoked.ts: available action rendered; isAvailable: () => false action absent; click hands back the full topic object; and clicking an action does not fire onOpen (this is the regression guard for the markup change). The existing given/a_topic_list_in_the_dom.ts already exports render/unmount/click — reuse it, add nothing.

    7. Stories — add a WithActions story to ChatTopicList.stories.tsx (Archive / Rename / Pin, fn() handlers, one gated by isAvailable), and topicActions on the ChatSidebar Playground story so it is visible in the sidebar context too.

    8. Docs — Documentation/Chat/message-actions.md already owns the action-descriptor story. Retitle its H1 to # Actions with ## Message actions / ## Topic actions subsections (keeping the filename, so no href or link breaks), update the toc.yml entry name to Actions, and add the topic-action descriptor block + a topicActions usage example. Also add a Key Features bullet in Documentation/Chat/index.md next to the existing "Extensible per-message actions shown on hover". A separate topic-actions.md page is an equally acceptable shape if the implementer prefers it — that choice is not load-bearing.

    Gates

    From /workspace/Source: npx tsc -p tsconfig.json --noEmit, yarn lint, npx vitest run Chat (must stay ≥ the 145-test baseline plus the new ones), and yarn build-storybook since stories change. yarn install succeeds in this environment, so all of these are runnable locally.

    PR

    Label minor — a new, non-breaking public prop and a new exported type. Not no-release: Source/** behavior changes for consumers.

    The one thing a human may want to overrule

    I settled the API as actions on ChatTopicList (exact mirror of ChatConversation, as the issue asked) and topicActions on ChatSidebar (forced — actions is already the message-actions prop there). The asymmetry is deliberate, but if Einari would rather have topicActions on both for symmetry, that is a one-word change and worth deciding before merge rather than after publishing. The issue explicitly leaves menu-vs-render-hook to us, so I took hover-revealed inline icon buttons — consistent with messages, no new PrimeReact overlay dependency, and a menu can be layered on later without changing this prop's shape.

    Suggested model for implementation: sonnet


    Posted by an automated agent. Review accordingly.

  2. cratis-direct commented on Sep 1, 2026

    @cratis-direct

    Plan

    Plan — #206: per-topic actions on ChatTopicList

    I re-verified every file-level claim in the prior investigation (already posted as a comment on the issue) against the current source tree. All of it holds:

    • ChatTopicListProps (Source/Chat/ChatTopicList.tsx:52-104) has no actions prop; each row (:157-209) is a <li><button className='cratis-chat-topics__topic'>...</button></li> — action buttons cannot nest inside it.
    • ChatMessageAction (Source/Chat/ChatMessageAction.ts) is the exact structural precedent to mirror.
    • ChatConversation.tsx:255-315 + ChatConversation.css:92-131 is the working reference implementation: hover-revealed overlay, opacity/pointer-events toggle (not display:none), icon-string-vs-node rendering.
    • ChatSidebarProps (ChatSidebar.tsx:83-86) already omits only messages | onSendMessage | labels | className from ChatConversationProps and forwards the rest via ...conversation — so actions is already spoken for by message actions there, confirming the sidebar needs a distinctly named topicActions prop, forwarded explicitly at ChatSidebar.tsx:356-374.
    • Spec/story precedents exist exactly as described: for_ChatConversation/when_a_message_action_is_invoked.ts, for_ChatTopicList/given/a_topic_list_in_the_dom.ts (exports render/unmount/click), ChatTopicList.stories.tsx (Playground, Empty), ChatSidebar.stories.tsx (Playground).
    • Documentation/Chat/message-actions.md + toc.yml + index.md's "Key Features" bullet list are exactly as described.

    No new hazards found. This is ready to implement as specified.

    Implementation steps, in order

    1. Source/Chat/ChatTopicAction.ts (new) — ChatTopicAction<TTopic extends ChatTopic = ChatTopic> with id: string, label: string, icon: string | ReactNode, isAvailable?: (topic: TTopic) => boolean, onInvoke: (topic: TTopic) => void. Structural mirror of ChatMessageAction, full TSDoc per member, license header.

    2. ChatTopicList.tsx

      • Add actions?: ChatTopicAction<TTopic>[] to ChatTopicListProps, documented as "the host's own actions, offered on every topic each is available for."
      • Per row: const availableActions = (actions ?? []).filter(a => a.isAvailable?.(topic) ?? true);
      • Restructure each <li> from a single <button role (implicit via <li>) wrapper into <li><div className='cratis-chat-topics__row'><button className='cratis-chat-topics__topic' onClick={...}>...(unchanged contents)...</button>{availableActions.length > 0 && <div className='cratis-chat-topics__actions'>...</div>}</div></li>.
      • Action buttons: <button className='cratis-chat-topics__action' title/aria-label={action.label} onClick={() => action.onInvoke(topic)}>, icon rendered typeof icon === 'string' ? <i className={icon} aria-hidden /> : icon — copy verbatim from ChatConversation.tsx:345-353.
      • No stopPropagation needed — the actions container is a sibling of the topic button, not nested inside it, so an action click can never bubble into onOpen.
    3. ChatTopicList.css

      • Move .cratis-chat-topics__topic:hover { background: var(--cratis-surface-hover); } to .cratis-chat-topics__row:hover .cratis-chat-topics__topic. Miss this and the hover highlight drops the instant the pointer moves onto an action.
      • Add .cratis-chat-topics__row { position: relative; }.
      • Add .cratis-chat-topics__actions — position: absolute; right: 0.375rem; top: 50%; transform: translateY(-50%); opacity: 0; pointer-events: none; transition: opacity 0.12s; plus the same overlay chrome as .cratis-chat-message__actions (background: var(--cratis-surface-overlay); border: 1px solid var(--cratis-surface-border); border-radius: 6px; box-shadow: ...; display: flex; gap: 2px; padding: 2px;).
      • Reveal rule: .cratis-chat-topics__row:hover .cratis-chat-topics__actions, .cratis-chat-topics__actions:focus-within { opacity: 1; pointer-events: auto; } — the pointer-events: none in the hidden state is required or an invisible overlay still swallows clicks on the activity timestamp it covers.
      • Add .cratis-chat-topics__action mirroring .cratis-chat-message__action.
      • Every color from --cratis-* tokens (file header comment already requires this).
    4. ChatSidebar.tsx — add topicActions?: ChatTopicAction<TTopic>[] to ChatSidebarProps, destructure it in the component, forward as actions={topicActions} on the <ChatTopicList<TTopic>> element (:356-374). ChatSidebarForObservableQueries needs no change — it spreads ...sidebar through untouched.

    5. Source/Chat/index.ts — add export type { ChatTopicAction } from './ChatTopicAction';, placed alphabetically after ChatTopic/ChatTopicList exports (:22-24).

    6. Specs — Source/Chat/for_ChatTopicList/when_a_topic_action_is_invoked.ts, modeled line-for-line on for_ChatConversation/when_a_message_action_is_invoked.ts, reusing given/a_topic_list_in_the_dom.ts (no changes needed there):

      • available action renders ([aria-label="..."] present)
      • an isAvailable: () => false action is absent
      • clicking an action hands back the full topic object
      • clicking an action does not also invoke onOpen — the regression guard for the markup restructure.
    7. Stories — add a WithActions story to ChatTopicList.stories.tsx (e.g. Archive/Rename/Pin, fn() handlers, one gated by isAvailable); add topicActions to the ChatSidebar.stories.tsx Playground story.

    8. Docs — Documentation/Chat/message-actions.md: retitle H1 to # Actions with ## Message actions / ## Topic actions subsections (filename unchanged, so no href/link breaks); add the ChatTopicAction descriptor block and a topicActions usage example, mirroring the existing message-action example. Update toc.yml's entry name from Message actions to Actions (href unchanged). Add a bullet to Documentation/Chat/index.md's Key Features list next to "Extensible per-message actions shown on hover" (e.g. "Extensible per-topic actions shown on hover").

    Settled design decisions (do not re-litigate)

    • Prop name asymmetry is intentional: actions on ChatTopicList (mirrors ChatConversation exactly, per the issue's suggested direction), topicActions on ChatSidebar (forced — actions there is already the message-actions prop, forwarded via the Omit<...> spread). See the flagged item below.
    • Hover-revealed inline icon buttons, not a menu — consistent with the existing message-actions pattern, no new overlay/menu dependency, and a menu can be layered on later without changing this prop's shape. The issue explicitly left this choice open.
    • Row markup changes from <button role='listitem'> to <div role='listitem' className='cratis-chat-topics__row'> wrapping the (unchanged, still-clickable) inner <button className='cratis-chat-topics__topic'>. No existing spec asserts on role='listitem' itself (when_a_topic_is_picked.ts and for_ChatSidebar/when_moving_between_topics_and_conversation.ts click on .cratis-chat-topics__topic, which is unaffected), so this is a safe, non-breaking internal restructure.

    Gates (run from /workspace/Source)

    • npx tsc -p tsconfig.json --noEmit
    • yarn lint
    • npx vitest run Chat — baseline is 30 files / 145 tests passing; must stay ≥ that plus the new spec file's assertions
    • yarn build-storybook (stories change)

    PR

    Label minor — new non-breaking public prop (ChatTopicListProps.actions, ChatSidebarProps.topicActions) and a new exported type (ChatTopicAction). Not no-release.

    One item worth a human sign-off before merge, not before starting

    The actions-on-list / topicActions-on-sidebar naming asymmetry is deliberate and load-bearing given ChatSidebar's existing actions prop — but if the maintainer (Einari) prefers topicActions on both components for symmetry instead, that's a one-word rename decided most cheaply before publishing rather than after. Implementation should proceed with the plan above; this is a nice-to-confirm, not a blocker.


    Posted by Stagehand (AI) - an autonomous agent, not a person. Review accordingly.

  3. woksin commented on Sep 28, 2026

    @woksin
    Contributor

    Decision: add a topicActions prop to both ChatTopicList and ChatSidebar. The sidebar's existing actions prop keeps meaning message actions only. Each action has a label and an optional availability check, and renders as a labeled control next to the button that opens the topic, not inside it, so the HTML stays valid. Topic-opening behavior does not change. Scheduled for 4.20.0.

  4. self-assigned this
    on Sep 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions