mezame 0.8.42

An ACP client that bridges a local agent (Kiro CLI, Claude Agent CLI, Gemini CLI, Codex, ...) to a browser UI over WebSockets.
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { BotIcon } from '@/components/BotIcon';
import { CopyButton } from '@/components/CopyButton';
import { Markdown } from '@/features/Markdown';
import { McpOauthCard } from '@/features/McpOauthCard';
import { ToolCallCard } from '@/features/ToolCallCard';
import { mezameActions } from '@/hooks/useMezame';
import { useKeyboardInsetValue } from '@/hooks/useKeyboardInset';
import { useTick } from '@/hooks/useTick';
import { cn } from '@/lib/utils';
import type { LogEntry, PermissionOption, Session } from '@/types';

type Props = {
  session: Session;
  isActive: boolean;
};

const PermissionCard = ({
  session,
  entry,
  options
}: {
  session: Session;
  entry: Extract<LogEntry, { kind: 'permission' }>;
  options: PermissionOption[];
}) => {
  const resolved = !!entry.resolution;
  // "Remember for this session" tickbox state. Resets per card because
  // each is its own React component instance; that's the right scope.
  const [remember, setRemember] = useState(false);
  // Whether this card is connected to a remembered-policy slot, i.e.
  // it either set the policy (`remembered`) or was auto-resolved by
  // it (`auto`). Subsequent auto cards keep the badge until the user
  // disables the policy on any matching card.
  const isRememberedCard = entry.remembered || entry.auto;
  // Active iff the policy is still live (not yet disabled). Looking
  // it up against the live session state means a click on Disable on
  // any card with the same title clears the badge on every other
  // card too, which matches the user's mental model: there is one
  // policy per title, not one per card.
  const policyActive = entry.title in session.rememberedPermissions;
  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="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
          <span>
            {`\u2192 ${entry.resolution}`}
            {entry.auto && (
              <span
                className="ml-1 rounded-sm bg-muted px-1 py-0.5 text-[10px] uppercase text-muted-foreground"
                title="Resolved automatically by a remembered policy"
              >
                auto
              </span>
            )}
          </span>
          {isRememberedCard && policyActive && (
            <>
              <span
                className="rounded-sm bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground"
                title="Future requests with this title auto-resolve until you disable the policy"
              >
                Remembered for this session
              </span>
              <button
                type="button"
                onClick={() =>
                  mezameActions.forgetRememberedPermission(session.id, entry.title)
                }
                className="cursor-pointer rounded-sm border border-border px-1.5 py-0.5 text-[11px] hover:bg-accent"
                title="Stop auto-resolving future requests with this title"
              >
                Forget
              </button>
            </>
          )}
        </div>
      ) : (
        <div className="flex flex-col gap-2">
          <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, remember)}
                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>
          <label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-muted-foreground">
            <input
              type="checkbox"
              checked={remember}
              onChange={(e) => setRemember(e.target.checked)}
              className="size-3 cursor-pointer"
            />
            Remember my choice and apply automatically next time
          </label>
        </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();

/** Placeholder avatar shown to the left of every agent bubble. The
 * design intentionally keeps this generic until we decide whether
 * sessions get distinct avatars (per-agent? per-model?). For now, a
 * flat primary-tinted disc with a small bot glyph is enough to give
 * the bubble its anchor point matching the reference design. */
const AgentAvatar = () => (
  <div
    aria-hidden="true"
    className="flex size-12 shrink-0 items-center justify-center rounded-full"
  >
    <BotIcon className="size-12" />
  </div>
);

const ThoughtCard = ({ entry }: { entry: Extract<LogEntry, { kind: 'thought' }> }) => {
  const trimmed = entry.text.trim();
  if (!trimmed) {
    return null;
  }
  // Native <details> keeps the open/closed state local to the DOM,
  // so React never has to thread a per-entry expansion flag through
  // the store.
  return (
    <details className="my-3 group">
      <summary className="cursor-pointer list-none inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-card/40 px-2.5 py-1 text-[11px] italic text-muted-foreground hover:bg-card/60 transition">
        <span className="inline-block size-1.5 rounded-full bg-[color:var(--primary)]/70 group-open:bg-[color:var(--primary)]" />
        Thought process
      </summary>
      <div className="mt-2 ml-2 border-l-2 border-border/50 pl-3 text-xs whitespace-pre-wrap break-words text-muted-foreground">
        {trimmed}
      </div>
    </details>
  );
};

const TextEntry = ({
  entry,
  isStreaming
}: {
  entry: Extract<LogEntry, { kind: 'text' }>;
  isStreaming: boolean;
}) => {
  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-5 flex items-start justify-start gap-2">
        <div className="flex flex-col items-center gap-2">
          <AgentAvatar />
          {!isStreaming && (
            <CopyButton text={copyText} title="Copy message" className="size-7" />
          )}
        </div>
        <div className="min-w-0 max-w-[88%] sm:max-w-[78%]">
          <div className="rounded-2xl rounded-tl-[0px] bg-[color:var(--agent-bubble)] px-4 py-4 text-[color:var(--agent-bubble-foreground)] shadow-sm">
            <Markdown text={entry.text} />
          </div>
        </div>
      </div>
    );
  }

  // User
  const cleaned = cleanUserText(entry.text);
  return (
    <div className="my-5 flex justify-end">
      <div className="max-w-[88%] sm:max-w-[78%]">
        <div className="rounded-2xl rounded-br-[0px] bg-[color:var(--user-bubble)] px-4 py-4 text-[color:var(--user-bubble-foreground)] shadow-sm">
          <div className="whitespace-pre-wrap break-words">{cleaned}</div>
        </div>
      </div>
    </div>
  );
};

export const LogPane = ({ session, isActive }: Props) => {
  const scrollRef = useRef<HTMLDivElement>(null);
  const lastScrollHeight = useRef(0);
  const tick = useTick();
  const kbInset = useKeyboardInsetValue();
  void tick;

  // Index of the last agent text entry. Used to gate the
  // copy-and-timestamp meta footer: while the session is streaming,
  // the trailing agent entry is in flux and its meta should stay
  // hidden until prompt_done lands. Earlier agent entries within
  // the same turn (interleaved with tool calls) are already final
  // and keep their footer.
  let lastAgentTextIndex = -1;
  for (let i = session.log.length - 1; i >= 0; i -= 1) {
    const e = session.log[i];
    if (e.kind === 'text' && e.role === 'agent') {
      lastAgentTextIndex = i;
      break;
    }
  }

  // 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 pt-3 break-words scrollbar-thin',
        !isActive && 'hidden'
      )}
      style={{
        paddingBottom:
          'calc(13rem + var(--mz-kb-inset) + var(--mz-safe-bottom))'
      }}
    >
      {session.log.map((entry, idx) => {
        if (entry.kind === 'text') {
          // Streaming meta-suppression: hide the copy button and
          // timestamp on the last agent text entry while the
          // session is still thinking; both will appear once
          // `prompt_done` clears the flag. Earlier agent entries
          // in the same turn (interleaved with tool calls) are
          // already final, so they keep their footer.
          const isStreaming =
            session.thinking && entry.role === 'agent' && idx === lastAgentTextIndex;
          return <TextEntry key={entry.id} entry={entry} isStreaming={isStreaming} />;
        }
        if (entry.kind === 'thought') {
          return <div key={entry.id} className="ml-14 max-w-[88%] sm:max-w-[78%]"><ThoughtCard entry={entry} /></div>;
        }
        if (entry.kind === 'tool_call') {
          return <div key={entry.id} className="ml-14 max-w-[88%] sm:max-w-[78%]"><ToolCallCard entry={entry} /></div>;
        }
        if (entry.kind === 'mcp_oauth') {
          return <div key={entry.id} className="ml-14 max-w-[88%] sm:max-w-[78%]"><McpOauthCard session={session} entry={entry} /></div>;
        }
        return (
          <div key={entry.id} className="ml-14 max-w-[88%] sm:max-w-[78%]"><PermissionCard session={session} entry={entry} options={entry.options} /></div>
        );
      })}

      {showThinking && (
        <div className="my-3 flex items-start gap-2">
          <AgentAvatar />
          <div
            className="rounded-2xl rounded-tl-[0px] bg-[color:var(--agent-bubble)] px-4 py-4 shadow-sm"
            role="status"
            aria-label="Agent is thinking"
          >
            <span className="inline-flex items-center gap-0.5 text-sm font-bold text-[color:var(--outline)] leading-normal">
              <span className="animate-bounce" style={{ animationDelay: '0ms' }}>.</span>
              <span className="animate-bounce" style={{ animationDelay: '150ms' }}>.</span>
              <span className="animate-bounce" style={{ animationDelay: '300ms' }}>.</span>
            </span>
          </div>
        </div>
      )}
    </div>
  );
};