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
9 changes: 9 additions & 0 deletions .changeset/react-doctor-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@intentface/chat": patch
---

Render-safety and re-render fixes from a React Doctor review.

**No ref writes during render.** `AskUser.Options` and `AskUser.Option` mirror their latest values into refs in an insertion effect instead of during render, which React may discard. It runs before every layout effect, so options still seed the initial highlight from their own. `Composer.Command`'s "this prefix is the one closing" flag is now state that flips only at open and close.

**Stable context values.** `AskUser.Root`, `AskUser.Options`, `AskUser.Option`, the shared collapsible behind `Reasoning` and `Steps`, and the anchored `Tabs` surface memoize their context values, so memoized consumers no longer redraw on every parent render.
6 changes: 4 additions & 2 deletions app/(chat)/playground/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"use client";

import { useMemo } from "react";
import { useState } from "react";
import { Chat } from "@/components/chat";

export default function Home() {
const chatId = useMemo(() => crypto.randomUUID(), []);
// A lazy state initializer mints the id exactly once per mount; useMemo may
// recompute it, which would swap the chat instance out from under the user.
const [chatId] = useState(() => crypto.randomUUID());

return <Chat chatId={chatId} />;
}
15 changes: 8 additions & 7 deletions components/ai/attachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import type { AttachmentErrorCode, AttachmentItem } from "@intentface/chat/attachments";
import { File, Paperclip, X } from "@keyline-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence } from "motion/react";
import * as m from "motion/react-m";
import Image from "next/image";
import type { ComponentProps, ReactNode } from "react";
import { useEffect, useState } from "react";
Expand Down Expand Up @@ -41,7 +42,7 @@ type AttachmentsRootProps = {
const AttachmentsRoot = ({ children, className, show = true }: AttachmentsRootProps) => (
<AnimatePresence initial={false}>
{show && (
<motion.div
<m.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
Expand All @@ -51,7 +52,7 @@ const AttachmentsRoot = ({ children, className, show = true }: AttachmentsRootPr
<div data-slot="attachments" className={cn("flex flex-wrap gap-2 px-2 pt-2", className)}>
<AnimatePresence initial={false}>{children}</AnimatePresence>
</div>
</motion.div>
</m.div>
)}
</AnimatePresence>
);
Expand All @@ -73,7 +74,7 @@ const AttachmentsItem = ({ item, children, className }: AttachmentsItemProps) =>
const filename = item.filename ?? "Attachment";
const Icon = getFileIcon(mediaType);
return (
<motion.div
<m.div
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
Expand Down Expand Up @@ -115,7 +116,7 @@ const AttachmentsItem = ({ item, children, className }: AttachmentsItemProps) =>
)}
</div>
{children}
</motion.div>
</m.div>
);
};

Expand Down Expand Up @@ -172,7 +173,7 @@ const AttachmentsDropzone = ({
const content = (
<AnimatePresence>
{show && (
<motion.div
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
Expand All @@ -181,7 +182,7 @@ const AttachmentsDropzone = ({
className={cn(dropzoneVariants[variant], className)}
>
{children ?? <span className="text-sm font-medium">Drop files here</span>}
</motion.div>
</m.div>
)}
</AnimatePresence>
);
Expand Down
15 changes: 8 additions & 7 deletions components/ai/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
useComposerStore,
useComposerSubmit,
} from "@intentface/chat/composer";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence } from "motion/react";
import * as m from "motion/react-m";
import { Children, type ComponentProps, type ReactNode, useMemo, useRef } from "react";
import { AskUser } from "@/components/ai/ask-user";
import { Attachments } from "@/components/ai/attachments";
Expand Down Expand Up @@ -129,7 +130,7 @@ const ComposerAttachments = ({
>
<AnimatePresence initial={false}>
{(attachments.isDragging || attachments.items.length > 0) && (
<motion.div
<m.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
Expand All @@ -155,7 +156,7 @@ const ComposerAttachments = ({
)}
<Attachments.Dropzone visible={attachments.isDragging} variant="inline" />
</div>
</motion.div>
</m.div>
)}
</AnimatePresence>
<Attachments.Error code={attachments.error} />
Expand Down Expand Up @@ -255,7 +256,7 @@ const ComposerPlaceholder = ({ placeholder, children, className }: ComposerPlace
return (
<div className="pointer-events-none flex min-h-lh">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
<m.span
key={key}
initial={{ opacity: 0, y: "100%", filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
Expand All @@ -264,7 +265,7 @@ const ComposerPlaceholder = ({ placeholder, children, className }: ComposerPlace
className={cn("text-ink-tertiary font-book leading-6", className)}
>
{typeof items[0] === "string" ? currentItem : items[key % items.length]}
</motion.span>
</m.span>
</AnimatePresence>
</div>
);
Expand Down Expand Up @@ -331,7 +332,7 @@ const ComposerSubmit = ({
{...props}
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
<m.span
key={isGenerating ? "stop" : "send"}
initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
Expand All @@ -340,7 +341,7 @@ const ComposerSubmit = ({
className="flex items-center justify-center"
>
{isGenerating ? <Stop /> : (children ?? <ArrowUp />)}
</motion.span>
</m.span>
</AnimatePresence>
</IconButton>
);
Expand Down
4 changes: 2 additions & 2 deletions components/ai/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
useMessageSelectionScope,
} from "@intentface/chat/message";
import { Check, Copy, File, MessageSquare, Paperclip, Stop } from "@keyline-icons/react";
import { motion } from "motion/react";
import * as m from "motion/react-m";
import Image from "next/image";
import { type ComponentProps, useMemo } from "react";
import { Chip } from "@/components/ai/chip";
Expand All @@ -29,7 +29,7 @@ type MessageRootProps = ComponentProps<typeof MessagePrimitive.Root>;
const MessageRoot = ({ className, ...props }: MessageRootProps) => (
<MessagePrimitive.Root
render={
<motion.div
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
Expand Down
7 changes: 4 additions & 3 deletions components/ai/reasoning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { Reasoning as ReasoningPrimitive, useReasoning } from "@intentface/chat/reasoning";
import { Brain, ChevronDown } from "@keyline-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence } from "motion/react";
import * as m from "motion/react-m";
import { type ComponentProps, memo, type ReactNode } from "react";
import { Markdown } from "@/components/ui/markdown";
import { splitReasoningByHeaders } from "@/lib/ai/message-info";
Expand Down Expand Up @@ -77,7 +78,7 @@ const ReasoningTrigger = memo(
<div className="flex items-center gap-1.5">
<Brain className="size-[15px]" />
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
<m.span
key={key}
initial={{ opacity: 0, y: "100%", filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
Expand All @@ -86,7 +87,7 @@ const ReasoningTrigger = memo(
className="whitespace-nowrap"
>
{component}
</motion.span>
</m.span>
</AnimatePresence>
</div>
<ChevronDown
Expand Down
9 changes: 5 additions & 4 deletions components/ai/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
useThreadVisibility,
} from "@intentface/chat/thread";
import { ArrowDown } from "@keyline-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { AnimatePresence } from "motion/react";
import * as m from "motion/react-m";
import type { ComponentProps } from "react";
import { memo, useCallback } from "react";
import Button from "@/components/ui/button";
Expand Down Expand Up @@ -121,7 +122,7 @@ const ThreadComposer = ({ className, children, ...props }: ThreadComposerProps)
// package's useThread hook so the motion enter/exit stays exactly as designed.
// ---------------------------------------------------------------------------

export type ThreadScrollButtonProps = ComponentProps<typeof motion.div>;
export type ThreadScrollButtonProps = ComponentProps<typeof m.div>;

const ThreadScrollButton = ({ className, ...props }: ThreadScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useThread();
Expand All @@ -135,7 +136,7 @@ const ThreadScrollButton = ({ className, ...props }: ThreadScrollButtonProps) =>
<div className="z-2 flex h-0 w-full max-w-(--thread-width) items-end justify-center">
<AnimatePresence>
{!isAtBottom && (
<motion.div
<m.div
initial={{ opacity: 0, y: 8, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.9 }}
Expand All @@ -146,7 +147,7 @@ const ThreadScrollButton = ({ className, ...props }: ThreadScrollButtonProps) =>
<ArrowDown />
Latest
</Button>
</motion.div>
</m.div>
)}
</AnimatePresence>
</div>
Expand Down
51 changes: 26 additions & 25 deletions components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import {
// Keyline has no sandbox icon, so this one stays on Tabler.
import { IconSandbox } from "@tabler/icons-react";
import type { ChatStatus } from "ai";
import { AnimatePresence, motion, stagger } from "motion/react";
import { AnimatePresence, stagger } from "motion/react";
import * as m from "motion/react-m";
import { useRouter } from "next/navigation";
import {
Children,
Expand Down Expand Up @@ -188,7 +189,7 @@ const TimelineStep = ({
<span className="transition-opacity group-hover/steps-trigger:opacity-0 group-data-open/steps-trigger:opacity-0">
<Icon className={cn("size-3.5", status === "active" && "animate-pulse")} />
</span>
<ChevronDown className="absolute size-4 opacity-0 transition-all group-hover/steps-trigger:opacity-100 group-data-open/steps-trigger:rotate-180 group-data-open/steps-trigger:opacity-100" />
<ChevronDown className="absolute size-4 opacity-0 transition-[opacity,rotate] group-hover/steps-trigger:opacity-100 group-data-open/steps-trigger:rotate-180 group-data-open/steps-trigger:opacity-100" />
</span>
<span className={labelClasses}>{label}</span>
</Steps.Trigger>
Expand Down Expand Up @@ -332,7 +333,7 @@ const InterleavedSteps = ({
// (roll matches the composer placeholder / TextLoop; shimmer only while active).
const header = (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
<m.span
key={displayLabel}
initial={{ opacity: 0, y: "100%", filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
Expand All @@ -341,7 +342,7 @@ const InterleavedSteps = ({
className="block"
>
{isActive ? <TextShimmer>{displayLabel}</TextShimmer> : displayLabel}
</motion.span>
</m.span>
</AnimatePresence>
);

Expand Down Expand Up @@ -564,8 +565,9 @@ const ChatMessages = () => {
const isError = status === "error";
const isStreaming = status === "streaming";

// Track messages present at mount — skip entrance animation for these
const initialMessageIds = useRef(new Set(messages.map((m) => m.id)));
// Track messages present at mount — skip entrance animation for these. Lazy:
// this component renders per stream chunk, and the set is only built once.
const [initialMessageIds] = useState(() => new Set(messages.map((message) => message.id)));

const turns = groupTurns(messages);
const lastMessageId = messages.at(-1)?.id;
Expand All @@ -581,7 +583,7 @@ const ChatMessages = () => {
isLast={message.id === lastMessageId}
isStreaming={message.id === lastMessageId && isStreaming}
isError={isError}
skipAnimation={initialMessageIds.current.has(message.id)}
skipAnimation={initialMessageIds.has(message.id)}
/>
))}
{turnIndex === turns.length - 1 && isError && (
Expand Down Expand Up @@ -667,11 +669,9 @@ const useAskUserPanelState = (
messages: readonly AppUIMessage[],
status: ChatStatus,
): AskUserPanelState | null => {
const prevRef = useRef<AskUserPanelState | null>(null);

const lastAssistant =
status === "ready" || status === "streaming"
? messages.findLast((m) => m.role === "assistant")
? messages.findLast((message) => message.role === "assistant")
: undefined;
// Widen to the package's structural part contract so the guard can narrow.
const parts: readonly UnknownPart[] = lastAssistant?.parts ?? [];
Expand All @@ -688,14 +688,15 @@ const useAskUserPanelState = (
}
: null;

if (
prevRef.current?.toolCallId === next?.toolCallId &&
(prevRef.current === null) === (next === null)
) {
return prevRef.current;
// Keep the first object seen for a tool call, so the result stays
// referentially stable while it waits. State adjusted during render rather
// than a ref written in it: it changes only when the tool call does.
const [current, setCurrent] = useState(next);
if (current?.toolCallId !== next?.toolCallId) {
setCurrent(next);
return next;
}
prevRef.current = next;
return next;
return current;
};

// Thin bridge — the only composer piece that re-renders per stream chunk. It derives
Expand Down Expand Up @@ -1041,7 +1042,7 @@ const ChatPlaceholder = () => {

return (
<AnimatePresence>
<motion.div
<m.div
variants={variants}
initial="hidden"
animate="visible"
Expand All @@ -1053,21 +1054,21 @@ const ChatPlaceholder = () => {
}}
className="flex flex-col items-center justify-center gap-4"
>
<motion.div variants={variants}>
<m.div variants={variants}>
<LogoTile size="lg" />
</motion.div>
</m.div>
<div className="flex flex-col items-center justify-center">
<motion.span
<m.span
variants={variants}
className="font-semibold text-[20px] text-ink-primary leading-7 tracking-[-0.01em]"
>
Intentface Chat
</motion.span>
<motion.span variants={variants} className="text-ink-secondary text-md">
</m.span>
<m.span variants={variants} className="text-ink-secondary text-md">
Start a conversation
</motion.span>
</m.span>
</div>
</motion.div>
</m.div>
</AnimatePresence>
);
};
Expand Down
Loading
Loading