Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1450,10 +1450,12 @@ describe("deriveMessagesTimelineRows", () => {
expect(byId.get("prompt")).not.toHaveProperty("groupedWithNext");
expect(byId.get("first-steer")).toMatchObject({
groupedWithNext: true,
groupedWithPrevious: false,
continuesSteer: false,
});
expect(byId.get("second-steer")).toMatchObject({
groupedWithNext: false,
groupedWithPrevious: true,
continuesSteer: true,
});
});
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,8 @@ type MessagesTimelineRowContent =
revertTurnCount?: number | undefined;
/** Another of your messages follows directly, so the two sit closer together. */
groupedWithNext?: boolean;
/** Another of your messages sits directly above, so the joined corners tighten. */
groupedWithPrevious?: boolean;
/** Set when the message above is a steer too, so the label shows once. */
continuesSteer?: boolean;
}
Expand Down Expand Up @@ -2146,12 +2148,11 @@ export function deriveMessagesTimelineRows(input: {
if (!timelineRowIsOwnUserMessage(row)) return row;
const previous = result[index - 1];
const groupedWithNext = timelineRowIsOwnUserMessage(result[index + 1]);
const groupedWithPrevious = timelineRowIsOwnUserMessage(previous);
const continuesSteer =
timelineRowIsOwnUserMessage(previous) &&
messageIsSteer(row.message) &&
messageIsSteer(previous.message);
if (!groupedWithNext && !continuesSteer) return row;
return { ...row, groupedWithNext, continuesSteer };
groupedWithPrevious && messageIsSteer(row.message) && messageIsSteer(previous.message);
if (!groupedWithNext && !groupedWithPrevious) return row;
return { ...row, groupedWithNext, groupedWithPrevious, continuesSteer };
});
}

Expand Down Expand Up @@ -2508,6 +2509,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary &&
a.revertTurnCount === bm.revertTurnCount &&
a.groupedWithNext === bm.groupedWithNext &&
a.groupedWithPrevious === bm.groupedWithPrevious &&
a.continuesSteer === bm.continuesSteer
);
}
Expand Down
147 changes: 99 additions & 48 deletions apps/web/src/components/chat/TimelineUserMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";
import type { ChatMarkdownContextReference } from "../ChatMarkdown";
import { cn } from "~/lib/utils";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock";
import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext";
Expand Down Expand Up @@ -174,9 +175,11 @@ function UserFileCard({
function UserVideoAttachment({
file,
standalone = false,
cornerClassName,
}: {
readonly file: ChatFileAttachment;
readonly standalone?: boolean;
readonly cornerClassName?: string | undefined;
}) {
const ctx = use(TimelineRowCtx);
// Row updates rebuild attachment objects, so the asset follows only the
Expand All @@ -198,7 +201,7 @@ function UserVideoAttachment({
className={cn(
"flex aspect-[4/3] w-full items-center justify-center bg-black px-2 py-3 text-center text-2xs text-white/70",
standalone
? cn(MEDIA_EDGE_CLASS_NAME, "rounded-2xl")
? cn(MEDIA_EDGE_CLASS_NAME, "rounded-2xl", cornerClassName)
: "rounded-lg border border-border/80",
)}
>
Expand Down Expand Up @@ -233,7 +236,9 @@ function UserVideoAttachment({
/>
);
return standalone ? (
<div className={cn(MEDIA_EDGE_CLASS_NAME, "overflow-hidden rounded-2xl")}>{player}</div>
<div className={cn(MEDIA_EDGE_CLASS_NAME, "overflow-hidden rounded-2xl", cornerClassName)}>
{player}
</div>
) : (
player
);
Expand Down Expand Up @@ -402,6 +407,7 @@ function UserMessageProjectedStatus({ row }: { row: Extract<TimelineRow, { kind:
function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
const ctx = use(TimelineRowCtx);
const { onImageExpand, onFileOpen } = ctx;
const coarsePointer = useMediaQuery({ pointer: "coarse" });
const senderThreadId = row.message.senderThreadId;
const resources = selectMessageImageResources(row.message.attachments);
const previewUrls = useAssetUrls(ctx.activeThreadEnvironmentId, resources);
Expand Down Expand Up @@ -484,6 +490,11 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
userVideos.length === 0 &&
regularImages.length > 1 &&
regularImages.every((image) => image.source?.kind !== "snap-shot");
// Corners that touch the bubble or a neighbouring message tighten, like the bubble's.
const mediaCornerClassName = cn(
row.groupedWithPrevious && "rounded-tr-md",
(mediaAboveText || row.groupedWithNext) && "rounded-br-md",
);
// Files sent without words show as cards, not as a bubble around a name chip.
const fileCardsOnly =
!folded &&
Expand Down Expand Up @@ -642,6 +653,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
? cn(
MEDIA_EDGE_CLASS_NAME,
"w-80 max-w-full gap-px overflow-hidden rounded-2xl bg-black/9 dark:bg-white/12",
mediaCornerClassName,
regularImages.length === 2
? "auto-rows-[12rem] grid-cols-2"
: regularImages.length === 3
Expand All @@ -665,11 +677,12 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
? cn(
MEDIA_EDGE_CLASS_NAME,
"min-h-16 justify-self-end overflow-hidden rounded-2xl",
loneImage && mediaCornerClassName,
)
: SNAP_SHOT_ATTACHMENT_FRAME_CLASS,
)
: loneImage
? cn(MEDIA_EDGE_CLASS_NAME, "overflow-hidden rounded-2xl")
? cn(MEDIA_EDGE_CLASS_NAME, "overflow-hidden rounded-2xl", mediaCornerClassName)
: collage
? cn(
"overflow-hidden bg-background",
Expand Down Expand Up @@ -730,14 +743,72 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
</div>
))}
{userVideos.map((file) => (
<UserVideoAttachment key={file.id} file={file} standalone={standaloneMedia} />
<UserVideoAttachment
key={file.id}
file={file}
standalone={standaloneMedia}
cornerClassName={
regularImages.length + userVideos.length === 1 ? mediaCornerClassName : undefined
}
/>
))}
</div>
) : null;

// Touch shows actions all the time, and narrow lanes have no room beside the
// bubble, so both keep the actions on their own line.
const floatActions = row.groupedWithNext === true && !coarsePointer;
const messageActions = (
<div
className={cn(
"flex items-center justify-end gap-x-2 gap-y-0.5",
floatActions ? "flex-wrap" : "shrink-0",
)}
>
{folded && senderThreadId ? (
<InlineButton onClick={() => ctx.onOpenThread(senderThreadId)} tone="muted">
Open thread
</InlineButton>
) : null}
<Tooltip>
<TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>
{formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)}
</TooltipTrigger>
<TooltipPopup>
{formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
</TooltipPopup>
</Tooltip>
<div className={cn("flex items-center gap-0.5", floatActions && "flex-wrap justify-end")}>
{typeof revertTurnCount === "number" && (
<RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} />
)}
{copyText && (
<MessageCopyButton
text={contextClipboardFragment ? copyText : plainText}
{...(contextClipboardFragment
? {
extraFlavors: {
[COMPOSER_CONTEXT_CLIPBOARD_MIME]: contextClipboardFragment,
},
}
: {})}
variant="ghost"
/>
)}
{promptLink !== null && (
<MessageCopyButton
text={promptLink}
label="Copy prompt link"
icon={LinkIconNode}
variant="ghost"
/>
)}
</div>
</div>
);
return (
<div
className="group flex flex-col items-end gap-1"
className="group @container flex flex-col items-end gap-1"
data-user-message-attribution={folded ? "agent" : undefined}
>
{userMessage.attribution === "t3code" ? (
Expand Down Expand Up @@ -769,7 +840,8 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
<div
className={cn(
"relative min-w-0 max-w-[80%] rounded-2xl text-message-foreground",
mediaAboveText && "rounded-tr-md",
(mediaAboveText || row.groupedWithPrevious) && "rounded-tr-md",
row.groupedWithNext && "rounded-br-md",
BUBBLE_CHIP_CLASS_NAME,
userMessage.attribution === "guest"
? guestBubbleClassName(false)
Expand Down Expand Up @@ -926,57 +998,36 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
)}
</div>
) : null}
{floatActions ? (
<div
className={cn(
"absolute end-full top-1/2 me-2 hidden w-max max-w-[calc(20cqw-0.5rem)] -translate-y-1/2 @min-[37rem]:flex",
ACTIONS_REVEAL_CLASS_NAME,
)}
>
{messageActions}
</div>
) : null}
</div>
<UserMessageProjectedStatus row={row} />
{!folded || expanded ? (
<div className="flex w-full max-w-[80%] items-center justify-end pe-1 text-xs tabular-nums opacity-0 transition-opacity duration-200 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover:opacity-100">
<div className="flex shrink-0 items-center gap-2">
{folded && senderThreadId ? (
<InlineButton onClick={() => ctx.onOpenThread(senderThreadId)} tone="muted">
Open thread
</InlineButton>
) : null}
<Tooltip>
<TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>
{formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)}
</TooltipTrigger>
<TooltipPopup>
{formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
</TooltipPopup>
</Tooltip>
<div className="flex items-center gap-0.5">
{typeof revertTurnCount === "number" && (
<RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} />
)}
{copyText && (
<MessageCopyButton
text={contextClipboardFragment ? copyText : plainText}
{...(contextClipboardFragment
? {
extraFlavors: {
[COMPOSER_CONTEXT_CLIPBOARD_MIME]: contextClipboardFragment,
},
}
: {})}
variant="ghost"
/>
)}
{promptLink !== null && (
<MessageCopyButton
text={promptLink}
label="Copy prompt link"
icon={LinkIconNode}
variant="ghost"
/>
)}
</div>
</div>
<div
className={cn(
"flex w-full max-w-[80%] justify-end pe-1",
floatActions && "@min-[37rem]:hidden",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
ACTIONS_REVEAL_CLASS_NAME,
)}
>
{messageActions}
</div>
) : null}
</div>
);
}

const ACTIONS_REVEAL_CLASS_NAME =
"items-center text-xs tabular-nums opacity-0 transition-opacity duration-200 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover:opacity-100";

function UserMessageIntentMarker({
intent,
}: {
Expand Down
Loading