feat: mark goal-sent chat messages

This commit is contained in:
Michael Suchacz
2026-05-27 15:15:20 +00:00
parent 8e14ccd878
commit a734cd36c6
2 changed files with 165 additions and 96 deletions
@@ -218,17 +218,20 @@ const buildUserMessage = ({
text,
files = [],
createdAt = baseMessage.created_at,
sentAsGoal = false,
}: {
id?: number;
text?: string;
files?: TypesGen.ChatFilePart[];
createdAt?: string;
sentAsGoal?: boolean;
}): TypesGen.ChatMessage => ({
...baseMessage,
created_at: createdAt,
id,
role: "user",
content: [...(text ? [buildTextPart(text)] : []), ...files],
...(sentAsGoal ? { sent_as_goal: true } : {}),
});
const buildStoryArgs = (...messages: TypesGen.ChatMessage[]) => ({
@@ -1041,6 +1044,41 @@ export const UserMessageTextOnly: Story = {
},
};
/** Goal-sent user messages show a durable transcript marker. */
export const UserMessageSentAsGoalMarker: Story = {
args: buildStoryArgs(
buildUserMessage({
id: 1,
text: "Use this screenshot as the goal",
files: [buildInlineAttachmentPart("image/png", TEST_PNG_B64)],
sentAsGoal: true,
}),
{
...baseMessage,
id: 2,
role: "assistant",
content: [{ type: "text", text: "I will pursue that goal." }],
},
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const marker = canvas.getByTestId("sent-as-goal-marker");
expect(marker).toBeVisible();
expect(marker).toHaveTextContent("Sent as goal");
expect(canvas.getAllByTestId("sent-as-goal-marker")).toHaveLength(1);
const markerActionRow = marker.closest('[data-testid="message-actions"]');
if (!(markerActionRow instanceof HTMLElement)) {
throw new Error("Sent as goal marker action row not found");
}
expect(
within(markerActionRow).queryByRole("button", {
name: "Copy message",
}),
).not.toBeInTheDocument();
},
};
/** Assistant-side images go through BlockList, not the user path. */
export const AssistantMessageWithImage: Story = {
args: {
@@ -1,4 +1,9 @@
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from "lucide-react";
import {
ChevronLeftIcon,
ChevronRightIcon,
PencilIcon,
TargetIcon,
} from "lucide-react";
import {
type FC,
Fragment,
@@ -80,6 +85,16 @@ const getChatMessageTextContent = (
return textContent.length > 0 ? textContent : undefined;
};
const SentAsGoalMarker: FC = () => (
<div
className="flex h-6 items-center gap-1 px-1 text-xs font-medium text-content-secondary"
data-testid="sent-as-goal-marker"
>
<TargetIcon className="size-3 shrink-0" aria-hidden />
<span>Sent as goal</span>
</div>
);
const ReasoningDisclosure = memo<{
id: string;
text: string;
@@ -589,6 +604,19 @@ const ChatMessageItem = memo<{
hasActiveStream,
isAwaitingFirstStreamChunk,
});
const hasUserMessageJumpControls = Boolean(
isUser &&
onJumpToUserMessage &&
(prevUserMessageId !== undefined || nextUserMessageId !== undefined),
);
const hasMessageControls = Boolean(
displayState.hasCopyableContent ||
(isUser && onEditUserMessage) ||
hasUserMessageJumpControls,
);
const showsSentAsGoalMarker = isUser && message.sent_as_goal === true;
const showsMessageActionRow =
!hideActions && (hasMessageControls || showsSentAsGoalMarker);
if (displayState.shouldHide) {
return null;
}
@@ -648,102 +676,105 @@ const ChatMessageItem = memo<{
</Message>
)}
</ConversationItem>
{!hideActions &&
(displayState.hasCopyableContent ||
(isUser && onEditUserMessage)) && (
<div
className={cn(
"mt-0.5 flex items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/msg:opacity-100",
isUser && "w-full justify-end",
)}
data-testid="message-actions"
>
{displayState.hasCopyableContent && (
<CopyButton
text={parsed.markdown}
label="Copy message"
className="size-6"
tooltipSide="bottom"
/>
)}
{isUser && onEditUserMessage && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
className="size-6"
aria-label="Edit message"
onClick={() => {
const { text, fileBlocks } =
getEditableUserMessagePayload(message);
onEditUserMessage(message.id, text, fileBlocks);
}}
>
<PencilIcon />
<span className="sr-only">Edit message</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Edit message</TooltipContent>
</Tooltip>
)}
{isUser &&
onJumpToUserMessage &&
(prevUserMessageId !== undefined ||
nextUserMessageId !== undefined) && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
className="size-6"
aria-label="Jump to previous user message"
disabled={prevUserMessageId === undefined}
onClick={() => {
if (prevUserMessageId !== undefined) {
onJumpToUserMessage(prevUserMessageId);
}
}}
>
<ChevronLeftIcon />
<span className="sr-only">
Jump to previous user message
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
Jump to previous user message
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
className="size-6"
aria-label="Jump to next user message"
disabled={nextUserMessageId === undefined}
onClick={() => {
if (nextUserMessageId !== undefined) {
onJumpToUserMessage(nextUserMessageId);
}
}}
>
<ChevronRightIcon />
<span className="sr-only">
Jump to next user message
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
Jump to next user message
</TooltipContent>
</Tooltip>
</>
{showsMessageActionRow && (
<div
className={cn(
"mt-0.5 flex items-center gap-1",
isUser && "w-full justify-end",
)}
data-testid="message-actions"
>
{hasMessageControls && (
<div className="flex items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/msg:opacity-100">
{displayState.hasCopyableContent && (
<CopyButton
text={parsed.markdown}
label="Copy message"
className="size-6"
tooltipSide="bottom"
/>
)}
</div>
)}
{isUser && onEditUserMessage && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
className="size-6"
aria-label="Edit message"
onClick={() => {
const { text, fileBlocks } =
getEditableUserMessagePayload(message);
onEditUserMessage(message.id, text, fileBlocks);
}}
>
<PencilIcon />
<span className="sr-only">Edit message</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Edit message</TooltipContent>
</Tooltip>
)}
{isUser &&
onJumpToUserMessage &&
(prevUserMessageId !== undefined ||
nextUserMessageId !== undefined) && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
className="size-6"
aria-label="Jump to previous user message"
disabled={prevUserMessageId === undefined}
onClick={() => {
if (prevUserMessageId !== undefined) {
onJumpToUserMessage(prevUserMessageId);
}
}}
>
<ChevronLeftIcon />
<span className="sr-only">
Jump to previous user message
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
Jump to previous user message
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
className="size-6"
aria-label="Jump to next user message"
disabled={nextUserMessageId === undefined}
onClick={() => {
if (nextUserMessageId !== undefined) {
onJumpToUserMessage(nextUserMessageId);
}
}}
>
<ChevronRightIcon />
<span className="sr-only">
Jump to next user message
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
Jump to next user message
</TooltipContent>
</Tooltip>
</>
)}
</div>
)}
{showsSentAsGoalMarker && <SentAsGoalMarker />}
</div>
)}
{displayState.needsAssistantBottomSpacer && (
<div className="min-h-6" data-testid="assistant-bottom-spacer" />
)}