Repository navigation
ChatTopicList offers no per-topic actions #206
Description
Activity
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.tsthat rendersChatTopicListwith anactionsprop 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 statusis clean and no source was changed.The same throwaway spec also pinned down the one real implementation hazard:
Source/Chat/ChatTopicList.tsx:133renders 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
ChatTopicListSettled by this investigation (do not re-litigate)
- The gap is real and reproduced. No
actions/topicActionsprop exists onChatTopicListProps(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 classcratis-chat-topics__topicon the inner button —for_ChatTopicList/when_a_topic_is_picked.ts:56andfor_ChatSidebar/when_moving_between_topics_and_conversation.ts:51click on that selector, and__name,__name--pending,__started-by,__startare asserted too. No spec depends onrole='listitem', so moving the role to the wrapper is free. ChatSidebarcannot call the propactions.ChatSidebarPropsextendsChatConversationPropsomitting onlymessages | onSendMessage | labels | className(ChatSidebar.tsx:46-47), soactionsis already taken by message actions and forwarded via...conversation. The sidebar's prop must betopicActions, forwarded explicitly to<ChatTopicList>atChatSidebar.tsx:260-268.ChatSidebarForObservableQueriesneeds no change — it spreads...sidebarstraight through.- The precedent to mirror is
ChatMessageAction(Source/Chat/ChatMessageAction.ts) + its render atChatConversation.tsx:223,255-278+ its CSS atChatConversation.css:92-131(absolute overlay,opacity/pointer-eventstoggle rather thandisplay:none, so a focused button stays reachable).
Implementation steps
-
Source/Chat/ChatTopicAction.ts(new) —ChatTopicAction<TTopic extends ChatTopic = ChatTopic>withid,label,icon: string | ReactNode,isAvailable?: (topic) => boolean,onInvoke: (topic) => void. A verbatim structural mirror ofChatMessageAction, full XML-ish TSDoc on every member, license header. (Deliberately not refactoring both onto a sharedChatAction<T>— that churns a published type name for no consumer benefit.) -
ChatTopicList.tsx- Add
actions?: ChatTopicAction<TTopic>[]toChatTopicListProps, 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 renderedtypeof icon === 'string' ? <i className={icon} aria-hidden/> : icon— identical toChatConversation.tsx:267-278. - Because the actions container is a sibling of the row button, no
stopPropagationis needed: an action click never reachesonOpen. Add a spec for that anyway (below).
- Add
-
ChatTopicList.css— three things, and the first two are easy to get wrong:- Move the hover highlight from
.cratis-chat-topics__topic:hoverto.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; }— mirroringChatConversation.css:108-112, including thepointer-events: nonein the hidden state (anopacity: 0overlay still swallows clicks otherwise). .cratis-chat-topics__row { position: relative; }, overlayposition: absolute; right: 0.375rem; top: 50%; transform: translateY(-50%);with--cratis-surface-overlaybackground, so it reads cleanly where it covers the activity timestamp. All colors from--cratis-*tokens, as the file's own header comment requires.
- Move the hover highlight from
-
ChatSidebar.tsx— addtopicActions?: ChatTopicAction<TTopic>[]toChatSidebarProps, destructure it, forward asactions={topicActions}. -
Source/Chat/index.ts—export type { ChatTopicAction } from './ChatTopicAction';(alphabetically afterChatTopic). Nopackage.jsonexports-map change:@cratis/components/Chatalready exists. -
Specs —
Source/Chat/for_ChatTopicList/when_a_topic_action_is_invoked.ts, modeled line-for-line onfor_ChatConversation/when_a_message_action_is_invoked.ts: available action rendered;isAvailable: () => falseaction absent; click hands back the full topic object; and clicking an action does not fireonOpen(this is the regression guard for the markup change). The existinggiven/a_topic_list_in_the_dom.tsalready exportsrender/unmount/click— reuse it, add nothing. -
Stories — add a
WithActionsstory toChatTopicList.stories.tsx(Archive / Rename / Pin,fn()handlers, one gated byisAvailable), andtopicActionson theChatSidebarPlayground story so it is visible in the sidebar context too. -
Docs —
Documentation/Chat/message-actions.mdalready owns the action-descriptor story. Retitle its H1 to# Actionswith## Message actions/## Topic actionssubsections (keeping the filename, so no href or link breaks), update thetoc.ymlentry name toActions, and add the topic-action descriptor block + atopicActionsusage example. Also add a Key Features bullet inDocumentation/Chat/index.mdnext to the existing "Extensible per-message actions shown on hover". A separatetopic-actions.mdpage 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), andyarn build-storybooksince stories change.yarn installsucceeds in this environment, so all of these are runnable locally.PR
Label
minor— a new, non-breaking public prop and a new exported type. Notno-release:Source/**behavior changes for consumers.The one thing a human may want to overrule
I settled the API as
actionsonChatTopicList(exact mirror ofChatConversation, as the issue asked) andtopicActionsonChatSidebar(forced —actionsis already the message-actions prop there). The asymmetry is deliberate, but if Einari would rather havetopicActionson 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.
- The gap is real and reproduced. No
Plan
Plan — #206: per-topic actions on
ChatTopicListI 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 noactionsprop; 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-131is the working reference implementation: hover-revealed overlay,opacity/pointer-eventstoggle (notdisplay:none), icon-string-vs-node rendering.ChatSidebarProps(ChatSidebar.tsx:83-86) already omits onlymessages | onSendMessage | labels | classNamefromChatConversationPropsand forwards the rest via...conversation— soactionsis already spoken for by message actions there, confirming the sidebar needs a distinctly namedtopicActionsprop, forwarded explicitly atChatSidebar.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(exportsrender/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
-
Source/Chat/ChatTopicAction.ts(new) —ChatTopicAction<TTopic extends ChatTopic = ChatTopic>withid: string,label: string,icon: string | ReactNode,isAvailable?: (topic: TTopic) => boolean,onInvoke: (topic: TTopic) => void. Structural mirror ofChatMessageAction, full TSDoc per member, license header. -
ChatTopicList.tsx- Add
actions?: ChatTopicAction<TTopic>[]toChatTopicListProps, 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 renderedtypeof icon === 'string' ? <i className={icon} aria-hidden /> : icon— copy verbatim fromChatConversation.tsx:345-353. - No
stopPropagationneeded — the actions container is a sibling of the topic button, not nested inside it, so an action click can never bubble intoonOpen.
- Add
-
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; }— thepointer-events: nonein the hidden state is required or an invisible overlay still swallows clicks on the activity timestamp it covers. - Add
.cratis-chat-topics__actionmirroring.cratis-chat-message__action. - Every color from
--cratis-*tokens (file header comment already requires this).
- Move
-
ChatSidebar.tsx— addtopicActions?: ChatTopicAction<TTopic>[]toChatSidebarProps, destructure it in the component, forward asactions={topicActions}on the<ChatTopicList<TTopic>>element (:356-374).ChatSidebarForObservableQueriesneeds no change — it spreads...sidebarthrough untouched. -
Source/Chat/index.ts— addexport type { ChatTopicAction } from './ChatTopicAction';, placed alphabetically afterChatTopic/ChatTopicListexports (:22-24). -
Specs —
Source/Chat/for_ChatTopicList/when_a_topic_action_is_invoked.ts, modeled line-for-line onfor_ChatConversation/when_a_message_action_is_invoked.ts, reusinggiven/a_topic_list_in_the_dom.ts(no changes needed there):- available action renders (
[aria-label="..."]present) - an
isAvailable: () => falseaction 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.
- available action renders (
-
Stories — add a
WithActionsstory toChatTopicList.stories.tsx(e.g. Archive/Rename/Pin,fn()handlers, one gated byisAvailable); addtopicActionsto theChatSidebar.stories.tsxPlaygroundstory. -
Docs —
Documentation/Chat/message-actions.md: retitle H1 to# Actionswith## Message actions/## Topic actionssubsections (filename unchanged, so no href/link breaks); add theChatTopicActiondescriptor block and atopicActionsusage example, mirroring the existing message-action example. Updatetoc.yml's entry name fromMessage actionstoActions(href unchanged). Add a bullet toDocumentation/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:
actionsonChatTopicList(mirrorsChatConversationexactly, per the issue's suggested direction),topicActionsonChatSidebar(forced —actionsthere is already the message-actions prop, forwarded via theOmit<...>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 onrole='listitem'itself (when_a_topic_is_picked.tsandfor_ChatSidebar/when_moving_between_topics_and_conversation.tsclick 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 --noEmityarn lintnpx vitest run Chat— baseline is 30 files / 145 tests passing; must stay ≥ that plus the new spec file's assertionsyarn build-storybook(stories change)
PR
Label
minor— new non-breaking public prop (ChatTopicListProps.actions,ChatSidebarProps.topicActions) and a new exported type (ChatTopicAction). Notno-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 givenChatSidebar's existingactionsprop — but if the maintainer (Einari) preferstopicActionson 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.
Decision: add a
topicActionsprop to bothChatTopicListandChatSidebar. The sidebar's existingactionsprop 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.
What is expected
ChatTopicListrenders each topic as a row that can be opened, but exposes no way to offer an action on a topic — archive, rename, pin, delete.ChatConversationalready has this shape for messages via itsactionsprop, so the pattern exists; the topic list simply has no equivalent.What happens instead
ChatTopicListPropshastopics,onOpen,onStart,authorOf,renderAvatar,isTopicUnnamed,buildAvatarUrl,labelsandclassName. There is noactions(ortopicActions) 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
ChatTopicListand 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
ChatConversationalready does for messages — an optionalactionsprop of per-topic actions (label, icon,onInvoke(topic), optionalisAvailable(topic)), rendered as a row-level menu. A render hook for trailing row content would work equally well; the decision is yours.