From ce1ebfe6c85221c77eb4d9b25e4d470e6cb4a19d Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Fri, 14 Aug 2026 08:49:17 -0400 Subject: [PATCH] fix(desktop): name project threads after their channel and reflow commit bodies Hard-wrapped git commit messages were rendered as chat line-breaks, so they stayed a narrow column. The conversation panel now titles itself with a clickable #channel, and the mentions card is labeled Related Conversations. Signed-off-by: Thomas Petersen --- .../channels/lib/threadPanelLayout.ts | 4 +++ .../messages/ui/MessageThreadPanel.tsx | 6 ++++ .../ui/MessageThreadPanelSkeleton.tsx | 29 ++++++++++++++++++- .../projects/ui/DiscussionChannels.tsx | 17 ++++++----- .../projects/ui/ProjectCommitDetailPanel.tsx | 5 +++- .../projects/ui/ProjectConversationPanel.tsx | 10 +++++++ .../projects/ui/ProjectRichContent.tsx | 9 +++++- desktop/src/shared/ui/markdown.tsx | 27 +++-------------- .../src/shared/ui/markdown/nodeCache.test.mjs | 20 +++++++++++++ desktop/src/shared/ui/markdown/nodeCache.ts | 5 +++- desktop/src/shared/ui/markdown/types.ts | 6 ++++ desktop/src/shared/ui/markdownUtils.ts | 28 ++++++++++++++++++ 12 files changed, 131 insertions(+), 35 deletions(-) diff --git a/desktop/src/features/channels/lib/threadPanelLayout.ts b/desktop/src/features/channels/lib/threadPanelLayout.ts index 92e02e65b..3d441bdaf 100644 --- a/desktop/src/features/channels/lib/threadPanelLayout.ts +++ b/desktop/src/features/channels/lib/threadPanelLayout.ts @@ -7,10 +7,14 @@ export type ThreadPanelLayoutProps = { columnMaxWidthPx?: number; enterMotion?: boolean; headerLeading?: React.ReactNode; + /** Replaces the default "Thread" label. Channel threads leave this unset. */ + headerTitle?: string; + headerTitleAriaLabel?: string; isFocusMode: boolean; isSinglePanelView?: boolean; layout?: "standalone" | "split"; showBackButton?: boolean; + onHeaderTitleClick?: () => void; onResetWidth?: () => void; onResizeStart?: React.PointerEventHandler; splitPaneClamp?: boolean; diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 69152b052..088ff4ad4 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -199,6 +199,8 @@ export function MessageThreadPanel({ editTarget, enterMotion, headerLeading, + headerTitle, + headerTitleAriaLabel, isSending, isFocusMode, isSinglePanelView = false, @@ -207,6 +209,7 @@ export function MessageThreadPanel({ onCancelEdit, onCancelReply, onClose, + onHeaderTitleClick, onResetWidth, onResizeStart, onDelete, @@ -967,9 +970,12 @@ export function MessageThreadPanel({ isHuddleTranscript ? undefined : ( ) diff --git a/desktop/src/features/messages/ui/MessageThreadPanelSkeleton.tsx b/desktop/src/features/messages/ui/MessageThreadPanelSkeleton.tsx index 087d57141..763591983 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanelSkeleton.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanelSkeleton.tsx @@ -20,17 +20,38 @@ import { Skeleton } from "@/shared/ui/skeleton"; /** Shared title row so the skeleton and loaded panel keep the same chrome. */ export function MessageThreadPanelHeader({ headerLeading, + headerTitle = "Thread", + headerTitleAriaLabel, isFocusMode, isSinglePanelView, onClose, + onHeaderTitleClick, showBackButton, }: { headerLeading?: React.ReactNode; + headerTitle?: string; + headerTitleAriaLabel?: string; isFocusMode: boolean; isSinglePanelView: boolean; onClose: () => void; + onHeaderTitleClick?: () => void; showBackButton?: boolean; }) { + const title = onHeaderTitleClick ? ( + + ) : ( + headerTitle + ); + return ( - Thread + {title} ); @@ -134,10 +155,13 @@ export function MessageThreadPanelSkeleton({ columnMaxWidthPx, enterMotion, headerLeading, + headerTitle, + headerTitleAriaLabel, isFocusMode, isSinglePanelView = false, layout = "standalone", onClose, + onHeaderTitleClick, onResetWidth, onResizeStart, showBackButton, @@ -194,9 +218,12 @@ export function MessageThreadPanelSkeleton({ header={ } diff --git a/desktop/src/features/projects/ui/DiscussionChannels.tsx b/desktop/src/features/projects/ui/DiscussionChannels.tsx index c639320b0..aa97b8286 100644 --- a/desktop/src/features/projects/ui/DiscussionChannels.tsx +++ b/desktop/src/features/projects/ui/DiscussionChannels.tsx @@ -71,14 +71,14 @@ function useChannelNameLookup(enabled: boolean) { } /** - * "Channels" card for PR, issue, and commit detail views: a bordered, - * softly tinted block with a small header that separates it from the - * surrounding text. Each channel gets a single truncating line — + * "Related Conversations" card for PR, issue, and commit detail views: a + * bordered, softly tinted block with a small header that separates it from + * the surrounding text. Each channel gets a single truncating line — * "Alice, Bob and Carol discussed this in #channel · 2h ago — snippet…" — * cut at the card edge regardless of screen width. Clicking the snippet - * jumps to that message (thread-aware), the same way inbox items do. - * Renders nothing until at least one channel references the entity, so - * the detail layout stays unchanged for undiscussed items. + * opens the conversation panel (forum hits still jump in-place). Renders + * nothing until at least one channel references the entity, so the detail + * layout stays unchanged for undiscussed items. */ export function DiscussedInChannels({ className, @@ -130,7 +130,7 @@ export function DiscussedInChannels({ data-testid={testId} >

- Channels + Related Conversations

{visible.map((channel) => { @@ -202,7 +202,8 @@ export function DiscussedInChannels({ onClick={() => setExpanded(true)} type="button" > - Show {hiddenCount} more {hiddenCount === 1 ? "channel" : "channels"} + Show {hiddenCount} more{" "} + {hiddenCount === 1 ? "conversation" : "conversations"} ) : null} {isTruncated ? ( diff --git a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx index 7937c3aba..9a4cd5bcf 100644 --- a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx @@ -115,7 +115,10 @@ export function ProjectCommitDetailPanel({
{diff?.commitBody ? ( - + ) : null} ) => void; widthPx: number; }) { + const { goChannel } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const activeChannel = channelsQuery.data?.find((channel) => channel.id === hit.channelId) ?? null; + const channelId = activeChannel?.id ?? hit.channelId ?? null; + const channelLabel = activeChannel?.name ?? hit.channelName ?? null; const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const membersQuery = useChannelMembersQuery(activeChannel?.id ?? null); @@ -183,12 +187,18 @@ export function ProjectConversationPanel({ threadRepliesQuery.isFetched && appliedExpansionKey !== expansionKey, )); + const openChannel = React.useCallback(() => { + if (channelId) void goChannel(channelId); + }, [channelId, goChannel]); const layoutProps = { canResetWidth, enterMotion: !canShowThread, + headerTitle: channelLabel ? `#${channelLabel}` : "Thread", + headerTitleAriaLabel: channelLabel ? `Open #${channelLabel}` : undefined, isFocusMode: false, isSinglePanelView: !isOverlay, layout: "standalone" as const, + onHeaderTitleClick: channelId ? openChannel : undefined, onResetWidth, onResizeStart, showBackButton: false, diff --git a/desktop/src/features/projects/ui/ProjectRichContent.tsx b/desktop/src/features/projects/ui/ProjectRichContent.tsx index d6e61b4d5..af728c017 100644 --- a/desktop/src/features/projects/ui/ProjectRichContent.tsx +++ b/desktop/src/features/projects/ui/ProjectRichContent.tsx @@ -10,10 +10,12 @@ import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; export function ProjectRichContent({ className = "text-sm", content, + hardLineBreaks, tags, }: { className?: string; content: string; + hardLineBreaks?: boolean; tags?: string[][]; }) { const imetaByUrl = React.useMemo( @@ -22,6 +24,11 @@ export function ProjectRichContent({ ); return ( - + ); } diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 433a8ce6e..0bb5f6085 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -45,8 +45,7 @@ import { classifyChildren, hasBlockMedia, isImageOnlyParagraph, - shallowArrayEqual, - shallowRecordEqual, + markdownPropsAreEqual, } from "./markdownUtils"; import { CODE_BLOCK_CLASS, @@ -1766,6 +1765,7 @@ function MarkdownInner({ configNudgeAuthorPubkey, content, customEmoji, + hardLineBreaks = true, imetaByUrl, interactive = true, agentMentionPubkeysByName, @@ -1882,6 +1882,7 @@ function MarkdownInner({ components: componentSet.components, content: processedContent, customEmoji, + hardLineBreaks, mentionNames, searchQuery, variant: componentSet.variant, @@ -1933,26 +1934,6 @@ function MarkdownInner({ ); } -export const Markdown = React.memo( - MarkdownInner, - (prev, next) => - prev.content === next.content && - prev.className === next.className && - prev.customEmoji === next.customEmoji && - prev.interactive === next.interactive && - prev.mediaInset === next.mediaInset && - shallowRecordEqual( - prev.agentMentionPubkeysByName, - next.agentMentionPubkeysByName, - ) && - shallowRecordEqual(prev.mentionPubkeysByName, next.mentionPubkeysByName) && - shallowArrayEqual(prev.mentionNames, next.mentionNames) && - shallowArrayEqual(prev.channelNames, next.channelNames) && - prev.imetaByUrl === next.imetaByUrl && - prev.configNudgeAuthorPubkey === next.configNudgeAuthorPubkey && - prev.searchQuery === next.searchQuery && - prev.snapshotSharedBy === next.snapshotSharedBy && - prev.videoReviewContext === next.videoReviewContext, -); +export const Markdown = React.memo(MarkdownInner, markdownPropsAreEqual); Markdown.displayName = "Markdown"; export { SyntaxHighlightedCode } from "./markdown/CodeBlock"; diff --git a/desktop/src/shared/ui/markdown/nodeCache.test.mjs b/desktop/src/shared/ui/markdown/nodeCache.test.mjs index bade50966..5ce9b8191 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.test.mjs +++ b/desktop/src/shared/ui/markdown/nodeCache.test.mjs @@ -109,6 +109,26 @@ test("oversized content bypasses the cache", () => { assert.notEqual(first, second); }); +test("hardLineBreaks changes the parse and the cache key", () => { + clearMarkdownNodeCache(); + const content = "hello\nworld"; + const withBreaks = renderCachedMarkdown({ ...BASE, content }); + const withoutBreaks = renderCachedMarkdown({ + ...BASE, + content, + hardLineBreaks: false, + }); + assert.notEqual(withBreaks, withoutBreaks); + assert.match(renderToStaticMarkup(withBreaks), /
{ clearMarkdownNodeCache(); const first = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 5853f8943..2ef447c06 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -65,6 +65,8 @@ export type MarkdownParseInputs = { components: Components; content: string; customEmoji?: CustomEmoji[]; + /** Omit or true for chat-style `
` on every newline. */ + hardLineBreaks?: boolean; mentionNames?: string[]; searchQuery?: string; variant: string; @@ -96,7 +98,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { components: input.components, remarkPlugins: [ remarkGfm, - remarkBreaks, + ...(input.hardLineBreaks === false ? [] : [remarkBreaks]), remarkSpoilers, remarkMessageLinks, [remarkMentions, { mentionNames: input.mentionNames }], @@ -130,6 +132,7 @@ export function renderCachedMarkdown( // distinct input tuples. Content is last and needs no prefix: everything // before it is self-delimiting. const key = + segment(input.hardLineBreaks === false ? "soft" : "hard") + segment(input.variant) + listSegment(input.mentionNames) + listSegment(input.channelNames) + diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 56f02ec1f..d31556ca2 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -63,6 +63,12 @@ export type MarkdownProps = { className?: string; content: string; customEmoji?: CustomEmoji[]; + /** + * When true (default), single newlines become `
` — chat Enter behavior. + * Git commit bodies are hard-wrapped at ~72 columns; pass false so those + * wraps reflow with the panel instead of staying a narrow column. + */ + hardLineBreaks?: boolean; imetaByUrl?: ImetaLookup; interactive?: boolean; agentMentionPubkeysByName?: Record; diff --git a/desktop/src/shared/ui/markdownUtils.ts b/desktop/src/shared/ui/markdownUtils.ts index 43365af84..f0ce894c6 100644 --- a/desktop/src/shared/ui/markdownUtils.ts +++ b/desktop/src/shared/ui/markdownUtils.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import type { MarkdownProps } from "./markdown/types"; + /** * Returns true when a React element is a block-level media wrapper (image or * video). The `img` component in `createMarkdownComponents` marks its output @@ -98,3 +100,29 @@ export function shallowRecordEqual( } return true; } + +export function markdownPropsAreEqual( + prev: MarkdownProps, + next: MarkdownProps, +): boolean { + return ( + prev.content === next.content && + prev.className === next.className && + prev.customEmoji === next.customEmoji && + prev.hardLineBreaks === next.hardLineBreaks && + prev.interactive === next.interactive && + prev.mediaInset === next.mediaInset && + shallowRecordEqual( + prev.agentMentionPubkeysByName, + next.agentMentionPubkeysByName, + ) && + shallowRecordEqual(prev.mentionPubkeysByName, next.mentionPubkeysByName) && + shallowArrayEqual(prev.mentionNames, next.mentionNames) && + shallowArrayEqual(prev.channelNames, next.channelNames) && + prev.imetaByUrl === next.imetaByUrl && + prev.configNudgeAuthorPubkey === next.configNudgeAuthorPubkey && + prev.searchQuery === next.searchQuery && + prev.snapshotSharedBy === next.snapshotSharedBy && + prev.videoReviewContext === next.videoReviewContext + ); +}