+ {attachments.length > 0 && (
+
+ {attachments.map((attachment, index) => attachment.type === 'image'
+ ? (
+
+ {renderSlot('conversation.chat.user.images', {
+ images: [attachment.image], loadImage, align: 'end', compact: compactImages,
+ })}
+
+ )
+ : (
+
+
+
+ {attachment.file.name}
+
+ {[fileExtension(attachment.file.name).toUpperCase().slice(0, 8), fileSizeText(attachment.file.bytes)]
+ .filter(Boolean).join(' ')}
+
+
+
+ ))}
+
+ )}
+ {showBubble &&
+ {renderFactorySlot('conversation.user.text', { text, facts, interactive, ellipsis })}
+ {extraBlocks.map((block, index) => (
+
+ ))}
+
}
+ {referenceLabels.length > 0 && (
+
+ {t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })}
+
+ )}
+
+ )
+}
diff --git a/packages/client/ui-chat/src/client/chat/flow-motion.ts b/packages/client/ui-chat/src/client/chat/flow-motion.ts
index 2804a93b77..9705f1f22a 100644
--- a/packages/client/ui-chat/src/client/chat/flow-motion.ts
+++ b/packages/client/ui-chat/src/client/chat/flow-motion.ts
@@ -1,7 +1,7 @@
/**
- * Fade-then-close transitions owned by one Chat viewport. Rows fade before their boxes close;
- * the owner reserves removed height and holds scrolling until that viewport's folds finish.
- * Motion is opt-in under `[data-chat-motion]`, respects reduced motion, and clears temporary styles.
+ * Fold transitions owned by one Chat viewport. Input folds coordinate box sizes and scrolling on
+ * one frame clock; other folds use CSS transitions. Motion is opt-in under `[data-chat-motion]`,
+ * respects reduced motion, and clears temporary styles.
*/
import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react'
import { useSearchableHidden, type HiddenTransition } from './searchable-hidden.ts'
@@ -24,12 +24,53 @@ export interface FlowMotionRows {
cancelMotion(element: HTMLElement): void
}
+/** Geometry captured before one input-owned fold changes its boxes. */
+export interface InputFoldBox {
+ readonly element: HTMLElement
+ readonly height: number
+ readonly marginTop: number
+ readonly closing: boolean
+}
+
+/** The viewport follows the same progress that changes the folded boxes. */
+export interface InputFoldDriver {
+ /**
+ * Capture geometry before the frame clock changes the boxes.
+ * @param boxes - non-nested boxes whose layout changes on this clock.
+ */
+ begin(boxes: readonly InputFoldBox[]): void
+ /**
+ * Reserve scroll range before box writes; omit when the driver needs no pre-write compensation.
+ * @param progress - shared eased progress from zero to one.
+ * @param cancelled - automatic following has stopped; reader-position compensation still applies.
+ */
+ prepare?(progress: number, cancelled: boolean): void
+ /**
+ * Apply viewport geometry after box writes, even when automatic following is cancelled.
+ * @param progress - shared eased progress from zero to one.
+ * @param cancelled - preserve the reader's position rather than applying the automatic target.
+ * @returns actual scrollTop displacement from the batch start, for closing-content visual compensation.
+ */
+ frame(progress: number, cancelled: boolean): number
+ /**
+ * Release temporary geometry after the last frame or cancellation.
+ * @param cancelled - clean up without reacquiring follow ownership or committing an obsolete landing.
+ */
+ finish(cancelled: boolean): void
+}
+
/** Animation lifetime and fold coordination for one viewport. */
export interface FlowMotion extends FlowMotionRows {
/** @returns whether a row owned by this viewport is closing. */
foldActive(): boolean
/** @param listener - callback after the last fold. @returns cancellation for a pending callback. */
onFoldIdle(listener: () => void): () => void
+ /** @param driver - one input-owned viewport motion. @returns whether an active fold was adopted. */
+ followInput(driver: InputFoldDriver): boolean
+ /** Stop input-owned scrolling while letting its closing rows finish. */
+ cancelInput(): void
+ /** @returns whether a coordinated fold currently owns layout and scrolling. */
+ inputActive(): boolean
/** Cancel owned animations and pending work when the viewport detaches. */
clear(): void
}
@@ -66,6 +107,101 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged:
const pendingReserve = new Set