mezame 0.8.2

An ACP client that bridges a local agent (Kiro CLI, Claude Agent CLI, Gemini CLI, Codex, ...) to a browser UI over WebSockets.
import { HistoryIcon, PlusIcon, XIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { Attention, ClosedEntry, Session } from '@/types';

// Fixed sidebar on desktop, slide-in drawer on mobile.
//
// Layout (top to bottom):
//   - Brand label (目覚め).
//   - Action row: History dropdown, New session button.
//   - Divider.
//   - Scrollable list of session rows, one tab per row, full width.
//
// On mobile the sidebar is hidden by default. `isOpen` is driven by the
// parent: the burger button in the chat pane flips it, and tapping any
// session row triggers `onRequestClose` so the drawer hides as the tab
// takes over the viewport.
//
// Active-state indicator is a 3 px accent bar on the row's left edge
// (replaces the chip-style ring used by the old horizontal bar). The
// status-driven fills (connected/connecting/error/busy-background) are
// unchanged.

type Props = {
  sessions: Session[];
  activeId: string | null;
  closed: ClosedEntry[];
  onActivate: (id: string) => void;
  onClose: (id: string) => void;
  onRename: (id: string, label: string) => void;
  onNewTab: () => void;
  onRestore: (acpSessionId: string) => void;
  onForget: (acpSessionId: string) => void;
  /** Drawer visibility on mobile. Ignored at desktop widths where the
   * sidebar is always rendered. */
  isOpen: boolean;
  /** Invoked when the drawer should close (tab activated, backdrop
   * tapped, close button pressed). No-op on desktop. */
  onRequestClose: () => void;
};

// Attention dot: fill carries the semantic (done/permission/error), a
// white outline plus drop shadow keeps it legible on top of any row
// background colour.
const attentionClass: Record<NonNullable<Attention>, string> = {
  done: 'bg-[color:var(--attn-done)]',
  permission: 'bg-[color:var(--attn-permission)]',
  error: 'bg-[color:var(--attn-error)]'
};

const attentionDotBase =
  'size-2 rounded-full ring-2 ring-background shadow-[0_0_0_1px_rgba(0,0,0,0.35)]';

// Per-status row backgrounds. Kept subtle (~18% of the accent colour)
// so many rows remain readable stacked; the active row still gets its
// left accent bar on top.
//
// The extra `busy-background` state pulses green for a tab that is
// still running a turn while the user has moved to another tab, so
// background work is never silently hidden. Precedence (top wins):
//   error > connecting/reconnecting > busy-in-background > connected.
type TabVisualState = 'connecting' | 'connected' | 'error' | 'busy-background';

const tabVisualState = (s: Session, isActive: boolean): TabVisualState => {
  if (s.status === 'error') {
    return 'error';
  }
  if (s.status === 'connecting' || s.status === 'reconnecting') {
    return 'connecting';
  }
  if (s.busy && !isActive) {
    return 'busy-background';
  }
  return 'connected';
};

const tabVisualClass: Record<TabVisualState, string> = {
  connecting: 'border-[color:var(--attn-permission)]/60 text-foreground',
  connected:
    'bg-[color:var(--attn-done)]/18 border-[color:var(--attn-done)]/45 text-foreground hover:bg-[color:var(--attn-done)]/28',
  error:
    'bg-[color:var(--attn-error)]/20 border-[color:var(--attn-error)]/55 text-foreground hover:bg-[color:var(--attn-error)]/30',
  // No Tailwind bg/border utilities here: the `tab-busy-border` class
  // owns them via a layered background (inner fill + conic gradient
  // around the border). See index.css.
  'busy-background': 'tab-busy-border text-foreground'
};

const tabVisualStyle: Partial<Record<TabVisualState, React.CSSProperties>> = {
  connecting: { animation: 'mezame-pulse-orange 1.4s ease-in-out infinite' }
};

const tabTooltipStatus = (state: TabVisualState): string => {
  switch (state) {
    case 'connecting':
      return 'Connecting...';
    case 'connected':
      return 'Connected';
    case 'error':
      return 'Disconnected';
    case 'busy-background':
      return 'Working...';
  }
};

const timeAgo = (ts: number): string => {
  const diff = Math.max(0, Date.now() - ts);
  const s = Math.floor(diff / 1000);
  if (s < 60) {
    return 'just now';
  }
  const m = Math.floor(s / 60);
  if (m < 60) {
    return `${m} min ago`;
  }
  const h = Math.floor(m / 60);
  if (h < 24) {
    return `${h} h ago`;
  }
  const d = Math.floor(h / 24);
  return `${d} d ago`;
};

export const SideBar = ({
  sessions,
  activeId,
  closed,
  onActivate,
  onClose,
  onRename,
  onNewTab,
  onRestore,
  onForget,
  isOpen,
  onRequestClose
}: Props) => {
  const [renamingId, setRenamingId] = useState<string | null>(null);
  const [renameValue, setRenameValue] = useState('');

  // Keep the active row visible when activation changes programmatically
  // (new session, restore from history, keyboard switch).
  useEffect(() => {
    if (!activeId) {
      return;
    }
    const row = document.querySelector(`[data-tab-id="${CSS.escape(activeId)}"]`);
    if (row instanceof HTMLElement) {
      row.scrollIntoView({ block: 'nearest' });
    }
  }, [activeId]);

  const commitRename = () => {
    if (renamingId && renameValue.trim()) {
      onRename(renamingId, renameValue.trim());
    }
    setRenamingId(null);
    setRenameValue('');
  };

  const handleActivate = (id: string) => {
    onActivate(id);
    // Drawer hides itself on mobile so the selected tab takes over the
    // viewport. No-op at desktop widths.
    onRequestClose();
  };

  return (
    <>
      {/* Backdrop: mobile-only, dims the chat while the drawer is
       * open so the modal affordance is unambiguous. Tapping it
       * closes the drawer. Hidden on desktop where the sidebar is
       * static. */}
      <div
        onClick={onRequestClose}
        className={cn(
          'fixed inset-0 z-30 bg-background/60 backdrop-blur-xs md:hidden',
          isOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
          'transition-opacity duration-200'
        )}
        aria-hidden="true"
      />

      <aside
        className={cn(
          // Base layout: fixed on the left, full height, scrolls its
          // own session list. Desktop always renders at translate-x-0;
          // mobile slides in from the left. Widths: 14 rem on desktop
          // (comfortable for a 20-character label with some room),
          // 16 rem on mobile drawer (touch targets are larger so the
          // extra width keeps labels legible).
          'fixed inset-y-0 left-0 z-40 flex w-72 flex-col',
          'border-r border-border/40 bg-background/95 backdrop-blur-md',
          'md:static md:w-64 md:bg-background/70',
          'transition-transform duration-200 ease-out',
          isOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'
        )}
        style={{
          paddingTop: 'var(--mz-safe-top)',
          paddingBottom: 'var(--mz-safe-bottom)',
          paddingLeft: 'var(--mz-safe-left)'
        }}
      >
        <div className="flex items-center justify-center px-3 pt-6 pb-5">
          <span className="text-[1.5rem] font-bold tracking-wider text-[color:var(--primary)] select-none">
            目覚め
          </span>
        </div>

        <div className="flex items-center gap-2 px-3 pb-2">
          <DropdownMenu>
            <Tooltip>
              <TooltipTrigger asChild>
                <DropdownMenuTrigger asChild>
                  <Button
                    size="icon"
                    variant="outline"
                    className="size-8 text-[color:var(--primary)]"
                    aria-label="History"
                  >
                    <HistoryIcon className="size-4" />
                  </Button>
                </DropdownMenuTrigger>
              </TooltipTrigger>
              <TooltipContent side="bottom">Recently closed</TooltipContent>
            </Tooltip>
            <DropdownMenuContent align="start">
              <DropdownMenuLabel>Recently closed</DropdownMenuLabel>
              <DropdownMenuSeparator />
              {closed.length === 0 ? (
                <div className="px-2 py-1.5 text-xs text-muted-foreground">No recently closed sessions</div>
              ) : (
                closed.map((entry) => (
                  <DropdownMenuItem
                    key={entry.acpSessionId}
                    onSelect={() => onRestore(entry.acpSessionId)}
                    className="flex-col items-stretch gap-0.5"
                  >
                    <div className="flex items-center justify-between gap-2">
                      <span className="text-sm text-foreground">{entry.label}</span>
                      <button
                        type="button"
                        className="rounded-sm px-1 text-muted-foreground/60 hover:text-[color:var(--attn-error)]"
                        onClick={(ev) => {
                          ev.stopPropagation();
                          ev.preventDefault();
                          onForget(entry.acpSessionId);
                        }}
                        aria-label="Forget"
                      >
                        <XIcon className="size-3" />
                      </button>
                    </div>
                    <div className="truncate text-[11px] text-muted-foreground">
                      {entry.cwd ? `${entry.cwd} · ` : ''}
                      {timeAgo(entry.closedAt)}
                    </div>
                  </DropdownMenuItem>
                ))
              )}
            </DropdownMenuContent>
          </DropdownMenu>

          <Tooltip>
            <TooltipTrigger asChild>
              <Button
                size="icon"
                variant="outline"
                className="size-8 text-[color:var(--primary)]"
                onClick={onNewTab}
                aria-label="New session"
              >
                <PlusIcon className="size-4" />
              </Button>
            </TooltipTrigger>
            <TooltipContent side="bottom">New session</TooltipContent>
          </Tooltip>

          {/* Mobile-only close button so the drawer can be dismissed
           * without tapping the backdrop. */}
          <Button
            size="icon"
            variant="ghost"
            className="size-8 ml-auto md:hidden"
            onClick={onRequestClose}
            aria-label="Close sidebar"
          >
            <XIcon className="size-4" />
          </Button>
        </div>

        <div className="mx-3 border-t border-[color:var(--primary)]/30" />

        <div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto scrollbar-thin px-3 py-2">
          {sessions.map((s) => {
            const isActive = s.id === activeId;
            const isRenaming = renamingId === s.id;
            const visual = tabVisualState(s, isActive);
            const visualClass = tabVisualClass[visual];
            // Attention dots signal "something finished in a tab you are
            // not looking at" (done) or "the agent is waiting on you"
            // (permission). Shown on Connected rows and on Busy-in-
            // background rows (so a pending permission prompt remains
            // visible on top of the green pulse). Suppressed on the
            // active row (the user is already looking) and when the row
            // carries its own strong colour (error / connecting).
            const showAttentionDot =
              !isActive &&
              s.attention !== null &&
              (visual === 'connected' || visual === 'busy-background');
            return (
              <Tooltip key={s.id}>
                <TooltipTrigger asChild>
                  <div
                    data-tab-id={s.id}
                    className={cn(
                      // Full-width row. h-9 (36 px) on desktop, h-11
                      // (44 px) on touch for thumb-friendly targets.
                      // Left padding reserves space for the active
                      // accent bar so content doesn't shift between
                      // states.
                      'group relative flex h-9 touch:h-11 w-full cursor-pointer items-center gap-2 rounded-sm border pl-3 pr-2 text-xs touch:text-[13px] select-none touch-manipulation',
                      visualClass
                    )}
                    style={tabVisualStyle[visual]}
                    onClick={() => !isRenaming && handleActivate(s.id)}
                    onDoubleClick={(ev) => {
                      ev.stopPropagation();
                      setRenamingId(s.id);
                      setRenameValue(s.label);
                    }}
                  >
                    {/* Active-row accent bar: sits inside the row's
                     * left padding so it never pushes content. */}
                    {isActive && (
                      <span
                        aria-hidden="true"
                        className="absolute inset-y-1 left-0.5 w-[3px] rounded-full bg-[color:var(--primary)]"
                      />
                    )}

                    {showAttentionDot && s.attention && (
                      <span className={cn(attentionDotBase, attentionClass[s.attention])} />
                    )}

                    {isRenaming ? (
                      <input
                        autoFocus
                        value={renameValue}
                        onChange={(e) => setRenameValue(e.target.value)}
                        onBlur={commitRename}
                        onClick={(e) => e.stopPropagation()}
                        onKeyDown={(e) => {
                          if (e.key === 'Enter') {
                            commitRename();
                          } else if (e.key === 'Escape') {
                            setRenamingId(null);
                            setRenameValue('');
                          }
                        }}
                        className="h-6 flex-1 rounded-sm bg-transparent px-1 text-base md:text-xs outline-hidden"
                      />
                    ) : (
                      <span className="min-w-0 flex-1 truncate">{s.label}</span>
                    )}

                    <button
                      type="button"
                      className="cursor-pointer rounded-sm p-0.5 touch:p-1.5 text-muted-foreground/60 hover:text-[color:var(--attn-error)]"
                      aria-label="Close session"
                      onClick={(ev) => {
                        ev.stopPropagation();
                        onClose(s.id);
                      }}
                    >
                      <XIcon className="size-3 touch:size-4" />
                    </button>
                  </div>
                </TooltipTrigger>
                <TooltipContent side="right">
                  <div>{s.cwd ? `${s.label} · ${s.cwd}` : s.label}</div>
                  <div className="text-muted-foreground mt-2">{tabTooltipStatus(visual)}</div>
                  <div className="text-muted-foreground">Double-click to rename.</div>
                </TooltipContent>
              </Tooltip>
            );
          })}
        </div>
      </aside>
    </>
  );
};