/**
* Marker — an inline note in a conversation.
*
* Transcripts are not only messages. Between the turns sit things the
* conversation did rather than said: a tool that ran, a file that was read, a
* model that changed, the point where yesterday ended. Those are not messages
* and should not look like one — a bubble implies a speaker, and none of these
* have one. A marker is the quieter row that carries them: small, muted, and
* aligned to the transcript rather than to a side of it.
*
* ```tsx
*
*
* Explored 4 files
*
* ```
*
* `shimmer` on the content is for the row that is still happening — the sweep
* runs through the glyphs while a step is in flight, and is dropped the moment
* it finishes.
*/
import { createContext, forwardRef, useContext, type ReactNode } from 'react';
import { View, type Text as RNText, type ViewProps } from 'react-native';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { IconColorProvider } from '../../icons';
import { AnimatedPressable, type AnimatedPressableProps } from '../../primitives/animated-pressable';
import { Text, type TextProps, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { Separator } from '../separator';
import { Shimmer } from '../shimmer';
type MarkerVariant = 'default' | 'border' | 'separator';
const markerVariants = tv({
slots: {
root: 'w-full flex-row items-center gap-2 py-1.5',
content: 'text-sm text-muted-foreground',
},
variants: {
variant: {
/** The plain row: an icon and a line of muted text. */
default: {},
/** Same row, closed by a hairline — for a step that ends a section. */
border: { root: 'border-b border-border pb-2.5' },
/**
* A centred label with a rule running out to each edge. The rules are
* `Separator`s rather than borders so they follow the same token and
* thickness as every other rule in the app.
*/
separator: { root: 'gap-3 py-3', content: 'text-xs uppercase tracking-wider' },
},
disabled: {
true: { root: 'opacity-[0.64]' },
},
},
defaultVariants: {
variant: 'default',
},
});
/** The parts read the variant rather than being told it twice. */
const MarkerContext = createContext('default');
export interface MarkerProps
extends Omit,
Omit, 'disabled'> {
className?: string;
disabled?: boolean;
/**
* `default` is the inline status row. `border` closes it with a hairline.
* `separator` centres the content between two rules — the "Yesterday"
* divider shape.
*/
variant?: MarkerVariant;
children?: ReactNode;
}
/**
* Renders as a pressable when given `onPress` and as a plain view otherwise,
* so a marker that does nothing does not announce itself as a button.
*/
const MarkerRoot = forwardRef(
({ className, variant = 'default', disabled, children, onPress, ...props }, ref) => {
const { root } = markerVariants({ variant, disabled: !!disabled });
// The rules belong to the root, not the content: the content has to stay a
// single centred child for `flex-1` on either side to split the remainder.
const inner =
variant === 'separator' ? (
<>
{textChildren(children)}
>
) : (
children
);
const body = !onPress ? (
{inner}
) : (
{inner}
);
return {body};
}
);
MarkerRoot.displayName = 'Marker';
export interface MarkerIconProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* Decorative leading slot. Hidden from screen readers — the icon repeats what
* the content already says, and announcing it twice is noise.
*/
const MarkerIcon = forwardRef(
({ className, children, ...props }, ref) => {
// Icons here take the muted foreground, so a marker reads as chrome next to
// a message rather than competing with it.
const muted = useCSSVariable('--color-muted-foreground');
return (
{textChildren(children)}
);
}
);
MarkerIcon.displayName = 'Marker.Icon';
export interface MarkerContentProps extends TextProps {
className?: string;
/**
* Sweep a highlight through the text, for a step that is still running.
* Drop it when the step finishes — a marker that shimmers forever reads as
* a stuck one.
*/
shimmer?: boolean;
children?: ReactNode;
}
const MarkerContent = forwardRef(
({ className, shimmer, children, ...props }, ref) => {
const variant = useContext(MarkerContext);
const { content } = markerVariants({ variant });
if (shimmer) {
// Shimmer owns the text so it can mask the sweep to the glyphs; the
// class goes to the text inside it rather than to the wrapping view.
return {children};
}
return (
{children}
);
}
);
MarkerContent.displayName = 'Marker.Content';
export const Marker = Object.assign(MarkerRoot, {
Icon: MarkerIcon,
Content: MarkerContent,
});