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 <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-14 08:49:17 -04:00
parent 670057a358
commit ce1ebfe6c8
12 changed files with 131 additions and 35 deletions
@@ -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<HTMLButtonElement>;
splitPaneClamp?: boolean;
@@ -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 : (
<MessageThreadPanelHeader
headerLeading={headerLeading}
headerTitle={headerTitle}
headerTitleAriaLabel={headerTitleAriaLabel}
isFocusMode={isFocusMode}
isSinglePanelView={isSinglePanelView}
onClose={onClose}
onHeaderTitleClick={onHeaderTitleClick}
showBackButton={showBackButton}
/>
)
@@ -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 ? (
<button
aria-label={headerTitleAriaLabel ?? `Open ${headerTitle}`}
className="min-w-0 max-w-full truncate text-left hover:underline"
data-testid="message-thread-open-channel"
onClick={onHeaderTitleClick}
title={headerTitleAriaLabel ?? `Open ${headerTitle}`}
type="button"
>
{headerTitle}
</button>
) : (
headerTitle
);
return (
<AuxiliaryPanelHeader backdrop>
<AuxiliaryPanelHeaderGroup
@@ -45,7 +66,7 @@ export function MessageThreadPanelHeader({
: undefined
}
>
<AuxiliaryPanelTitle>Thread</AuxiliaryPanelTitle>
<AuxiliaryPanelTitle>{title}</AuxiliaryPanelTitle>
</AuxiliaryPanelHeaderGroup>
</AuxiliaryPanelHeader>
);
@@ -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={
<MessageThreadPanelHeader
headerLeading={headerLeading}
headerTitle={headerTitle}
headerTitleAriaLabel={headerTitleAriaLabel}
isFocusMode={isFocusMode}
isSinglePanelView={isSinglePanelView}
onClose={onClose}
onHeaderTitleClick={onHeaderTitleClick}
showBackButton={showBackButton}
/>
}
@@ -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}
>
<h4 className="border-b border-border/40 px-3 py-1.5 text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
Channels
Related Conversations
</h4>
<div className="divide-y divide-border/40">
{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"}
</button>
) : null}
{isTruncated ? (
@@ -115,7 +115,10 @@ export function ProjectCommitDetailPanel({
</div>
</div>
{diff?.commitBody ? (
<ProjectRichContent content={diff.commitBody} />
<ProjectRichContent
content={diff.commitBody}
hardLineBreaks={false}
/>
) : null}
<DiscussedInChannels
entityLabel="this commit"
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
useChannelMembersQuery,
useChannelsQuery,
@@ -48,9 +49,12 @@ export function ProjectConversationPanel({
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => 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,
@@ -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 (
<Markdown className={className} content={content} imetaByUrl={imetaByUrl} />
<Markdown
className={className}
content={content}
hardLineBreaks={hardLineBreaks}
imetaByUrl={imetaByUrl}
/>
);
}
+4 -23
View File
@@ -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";
@@ -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), /<br/i);
assert.doesNotMatch(renderToStaticMarkup(withoutBreaks), /<br/i);
const withoutBreaksAgain = renderCachedMarkdown({
...BASE,
content,
hardLineBreaks: false,
});
assert.equal(withoutBreaks, withoutBreaksAgain);
});
test("active search queries bypass the cache", () => {
clearMarkdownNodeCache();
const first = renderCachedMarkdown({ ...BASE, searchQuery: "bold" });
+4 -1
View File
@@ -65,6 +65,8 @@ export type MarkdownParseInputs = {
components: Components;
content: string;
customEmoji?: CustomEmoji[];
/** Omit or true for chat-style `<br>` 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) +
+6
View File
@@ -63,6 +63,12 @@ export type MarkdownProps = {
className?: string;
content: string;
customEmoji?: CustomEmoji[];
/**
* When true (default), single newlines become `<br>` 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<string, string>;
+28
View File
@@ -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
);
}