import { useEffect, useLayoutEffect, useRef } from 'react';
import { CopyButton } from '@/components/CopyButton';
import { Markdown } from '@/features/Markdown';
import { ToolCallCard } from '@/features/ToolCallCard';
import { mezameActions } from '@/hooks/useMezame';
import { useKeyboardInsetValue } from '@/hooks/useKeyboardInset';
import { useTick } from '@/hooks/useTick';
import { formatAbsolute, timeAgo } from '@/lib/time';
import { cn } from '@/lib/utils';
import type { LogEntry, PermissionOption, Session } from '@/types';
type Props = {
session: Session;
isActive: boolean;
};
const TimestampLabel = ({ ts, now }: { ts: number; now: number }) => (
<span className="text-[11px] text-muted-foreground select-none" title={formatAbsolute(ts)}>
{timeAgo(ts, now)}
</span>
);
const PermissionCard = ({
session,
entry,
options
}: {
session: Session;
entry: Extract<LogEntry, { kind: 'permission' }>;
options: PermissionOption[];
}) => {
const resolved = !!entry.resolution;
const optionTone = (opt: PermissionOption) => {
const kind = (opt.kind || opt.optionId || '').toString();
if (kind.startsWith('allow')) {
return 'border-[color:var(--attn-done)]/40 text-[color:var(--attn-done)] hover:bg-[color:var(--attn-done)]/10';
}
if (kind.startsWith('reject')) {
return 'border-[color:var(--attn-error)]/40 text-[color:var(--attn-error)] hover:bg-[color:var(--attn-error)]/10';
}
return 'border-border text-foreground hover:bg-accent';
};
return (
<div
className={cn(
'my-3 rounded-sm border border-l-[3px] border-l-[color:var(--attn-permission)] bg-card px-3 py-2',
resolved && 'border-l-muted-foreground opacity-60'
)}
>
<div className="mb-2 text-xs text-[color:var(--attn-permission)]">
permission requested: {entry.title}
</div>
{resolved ? (
<div className="text-xs text-muted-foreground">→ {entry.resolution}</div>
) : (
<div className="flex flex-col gap-2 md:flex-row md:flex-wrap md:gap-1.5">
{options.map((opt) => (
<button
key={opt.optionId}
type="button"
onClick={() => mezameActions.resolvePermission(session.id, entry.id, opt)}
className={cn(
// Stacked on mobile with 44 px minimum height so each
// option has a clearly separate hit area; inline on
// desktop with the denser sizing.
'cursor-pointer rounded-sm border bg-card text-sm md:text-xs transition-colors',
'min-h-11 md:min-h-0 px-4 md:px-2.5 py-2.5 md:py-1',
optionTone(opt)
)}
>
{opt.name || opt.optionId || 'option'}
</button>
))}
</div>
)}
</div>
);
};
/** Strip the legacy terminal-prompt glyph and trailing whitespace that
* the store prepends/appends. Neither belongs in a chat bubble. */
const cleanUserText = (text: string): string => text.replace(/^> /, '').trimEnd();
const TextEntry = ({ entry, now }: { entry: Extract<LogEntry, { kind: 'text' }>; now: number }) => {
if (entry.role === 'sys') {
// Pure-whitespace glue (trailing newlines used to enforce terminal
// spacing) is dropped entirely in the bubble layout.
const trimmed = entry.text.trim();
if (!trimmed) {
return null;
}
const isError = trimmed.startsWith('[Error');
return (
<div className="my-2 flex justify-center">
<div
className={cn(
'max-w-[90%] rounded-md border px-2.5 py-1 text-[11px] italic text-center',
isError
? 'border-[color:var(--attn-error)]/40 bg-[color:var(--attn-error)]/10 text-[color:var(--attn-error)] not-italic'
: 'border-border/60 bg-card/40 text-muted-foreground'
)}
>
{trimmed}
</div>
</div>
);
}
if (entry.role === 'agent') {
const copyText = entry.text.trim();
return (
<div className="my-3">
<Markdown text={entry.text} />
<div className="mt-2 flex items-center gap-2.5">
<CopyButton text={copyText} title="Copy message" className="size-7" />
<TimestampLabel ts={entry.timestamp} now={now} />
</div>
</div>
);
}
// User
const cleaned = cleanUserText(entry.text);
return (
<div className="mt-10 mb-6 flex justify-end">
<div className="max-w-[90%] rounded-2xl rounded-br-sm border border-[color:var(--user-bubble)]/40 bg-[color:var(--user-bubble)]/15 px-4 py-3 sm:max-w-[78%]">
<div className="whitespace-pre-wrap break-words text-foreground">{cleaned}</div>
<div className="mt-2 flex items-center gap-2.5">
<TimestampLabel ts={entry.timestamp} now={now} />
<CopyButton text={cleaned} title="Copy message" className="size-7" />
</div>
</div>
</div>
);
};
export const LogPane = ({ session, isActive }: Props) => {
const scrollRef = useRef<HTMLDivElement>(null);
const lastScrollHeight = useRef(0);
const tick = useTick();
const kbInset = useKeyboardInsetValue();
// Relative-time label refresh needs an actual `now` snapshot. tick just
// forces this component (and children) to re-render.
const now = Date.now();
void tick;
// Auto-scroll when new content arrives if the user is pinned to the
// bottom. useLayoutEffect so the scroll happens in the same frame as
// the DOM update, avoiding visible jumps.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) {
return;
}
const grew = el.scrollHeight > lastScrollHeight.current;
lastScrollHeight.current = el.scrollHeight;
if (grew && session.pinnedToBottom) {
el.scrollTop = el.scrollHeight;
}
});
// Re-pin the scroll to the bottom when the virtual keyboard opens
// or closes. Without this the content that was flush with the
// composer would end up either stranded above the now-lifted
// composer (keyboard open) or leaving a gap below the new resting
// position (keyboard closed). Only runs when the session was
// already pinned; if the user had scrolled up to read older
// messages, we leave their scroll position alone.
useEffect(() => {
const el = scrollRef.current;
if (!el || !session.pinnedToBottom) {
return;
}
el.scrollTop = el.scrollHeight;
}, [kbInset, session.pinnedToBottom]);
useEffect(() => {
const el = scrollRef.current;
if (!el) {
return;
}
const onScroll = () => {
const pinned = el.scrollHeight - el.scrollTop - el.clientHeight < 20;
mezameActions.setPinnedToBottom(session.id, pinned);
};
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, [session.id]);
// Hide the thinking indicator once the agent has started streaming a
// reply: the growing bubble is its own progress signal. Only show it
// between submit and the first agent chunk (or for tool-call-only
// turns where no agent text arrives at all).
const lastEntry = session.log.at(-1);
const agentStreaming =
lastEntry && lastEntry.kind === 'text' && lastEntry.role === 'agent';
const showThinking = session.thinking && !agentStreaming;
return (
<div
ref={scrollRef}
className={cn(
// Bottom padding leaves room for the floating composer so the
// final message isn't permanently hidden. The 13rem base
// covers the composer at its MIN_ROWS height plus gutters;
// `--mz-kb-inset` lifts the reserved area above the virtual
// keyboard when it is open, and `--mz-safe-bottom` clears
// the iOS home indicator. Both vars default to 0 on desktop.
'flex-1 overflow-y-auto px-3 pt-3 break-words scrollbar-thin',
!isActive && 'hidden'
)}
style={{
paddingBottom:
'calc(13rem + var(--mz-kb-inset) + var(--mz-safe-bottom))'
}}
>
{session.log.map((entry) => {
if (entry.kind === 'text') {
return <TextEntry key={entry.id} entry={entry} now={now} />;
}
if (entry.kind === 'tool_call') {
return <ToolCallCard key={entry.id} entry={entry} />;
}
return (
<PermissionCard key={entry.id} session={session} entry={entry} options={entry.options} />
);
})}
{showThinking && (
<div className="my-3 inline-flex items-center gap-2 rounded-md border border-[color:var(--primary)]/30 bg-[color:var(--primary)]/10 px-2.5 py-1.5 text-xs text-muted-foreground">
<span
role="status"
aria-label="thinking"
className="inline-block size-2.5 animate-spin rounded-full border-2 border-[color:var(--primary)] border-t-transparent"
/>
thinking
</div>
)}
</div>
);
};