diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index c9aec993bcd86..4c7eba620b5d1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -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, }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 1f22bc1c14978..2fe811db4d18f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -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; } @@ -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 }; }); } @@ -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 ); } diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 48086d40b4bf3..e73f5d1d1e9a7 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -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"; @@ -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 @@ -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", )} > @@ -233,7 +236,9 @@ function UserVideoAttachment({ /> ); return standalone ? ( -
{player}
+
+ {player} +
) : ( player ); @@ -402,6 +407,7 @@ function UserMessageProjectedStatus({ row }: { row: Extract }) { 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); @@ -484,6 +490,11 @@ function UserTimelineRow({ row }: { row: Extract 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 && @@ -642,6 +653,7 @@ function UserTimelineRow({ row }: { row: Extract ))} {userVideos.map((file) => ( - + ))} ) : 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 = ( +
+ {folded && senderThreadId ? ( + ctx.onOpenThread(senderThreadId)} tone="muted"> + Open thread + + ) : null} + + }> + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} + + + {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} + + +
+ {typeof revertTurnCount === "number" && ( + + )} + {copyText && ( + + )} + {promptLink !== null && ( + + )} +
+
+ ); return (
{userMessage.attribution === "t3code" ? ( @@ -769,7 +840,8 @@ function UserTimelineRow({ row }: { row: Extract ) : null} + {floatActions ? ( + + ) : null}
{!folded || expanded ? ( -
-
- {folded && senderThreadId ? ( - ctx.onOpenThread(senderThreadId)} tone="muted"> - Open thread - - ) : null} - - }> - {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} - - - {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} - - -
- {typeof revertTurnCount === "number" && ( - - )} - {copyText && ( - - )} - {promptLink !== null && ( - - )} -
-
+
+ {messageActions}
) : null}
); } +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, }: {