mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user