Files
openchamber/packages/ui/src/components/chat/StatusRow.tsx
T
Bohdan Triapitsyn 8587db89b1 perf: reduce UI render fanout and scroll jitter
- Cut broad render fanout across the app by replacing shared-store whole-object subscriptions with leaf selectors, memoizing hot chrome boundaries, and isolating disabled global providers from live session/message state. This keeps header controls, composer toolbars, side panels, and other non-hot UI surfaces from repainting on every assistant update or keystroke.

- Rework sidebar session ordering so recent, project groups, and worktree groups derive from one ordering source while avoiding streaming-time thrash. The sidebar now uses a stabilized session snapshot, preserves structural identity for unchanged rows, reads live row status/details per session, and applies a one-shot sort bump on idle->busy instead of continuously resorting during activity.

- Fix chat/input scroll instability by separating viewport-resize handling from message-growth handling, disabling conflicting native scroll anchoring, and stopping textarea autosize from collapsing on every growth keystroke. This removes the multiline typing jiggle during streaming and reduces unnecessary composer rerenders.

- Also gate voice context wiring behind voice-mode enablement and codify the learned render/scroll/order anti-patterns in AGENTS.md so future changes avoid the same classes of regressions.
2026-04-04 02:19:55 +03:00

327 lines
11 KiB
TypeScript

import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import {
RiArrowDownSLine,
RiArrowUpDoubleLine,
RiArrowUpSLine,
RiCheckboxCircleLine,
RiCloseCircleLine,
RiRecordCircleLine,
RiTimeLine,
} from "@remixicon/react";
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
// Compat aliases for old TodoItem shape
type TodoItem = Todo & { id?: string };
type TodoStatus = string;
type TodoPriority = string;
import { useUIStore } from "@/stores/useUIStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
textClassName: "text-foreground",
},
pending: {
textClassName: "text-foreground",
},
completed: {
textClassName: "text-muted-foreground line-through",
},
cancelled: {
textClassName: "text-muted-foreground line-through",
},
};
const priorityClassName: Record<TodoPriority, string> = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
high: <RiArrowUpDoubleLine className="h-3.5 w-3.5" aria-hidden="true" />,
medium: <RiArrowUpSLine className="h-3.5 w-3.5" aria-hidden="true" />,
low: <RiArrowDownSLine className="h-3.5 w-3.5" aria-hidden="true" />,
};
const statusLabel: Record<TodoStatus, string> = {
in_progress: "In progress",
pending: "Pending",
completed: "Completed",
cancelled: "Cancelled",
};
const priorityLabel: Record<TodoPriority, string> = {
high: "High priority",
medium: "Medium priority",
low: "Low priority",
};
interface TodoItemRowProps {
todo: TodoItem;
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const config = statusConfig[todo.status] || statusConfig.pending;
const statusIcon =
todo.status === "in_progress" ? (
<RiRecordCircleLine className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
) : todo.status === "completed" ? (
<RiCheckboxCircleLine className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
) : (
<RiTimeLine className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{statusLabel[todo.status] ?? statusLabel.pending}
</TooltipContent>
</Tooltip>
<span
className={cn(
"flex-1 typography-ui-label",
config.textClassName
)}
>
{todo.content}
</span>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[todo.priority] ?? priorityClassName.medium
)}
>
{priorityIcon[todo.priority] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{priorityLabel[todo.priority] ?? priorityLabel.medium}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface StatusRowProps {
// Working state
isWorking?: boolean;
statusText?: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
wasAborted?: boolean;
abortActive?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
// Abort state (for mobile/vscode)
showAbort?: boolean;
onAbort?: () => void;
// Abort status display
showAbortStatus?: boolean;
showAssistantStatus?: boolean;
showTodos?: boolean;
agentName?: string;
}
export const StatusRow: React.FC<StatusRowProps> = ({
isWorking = false,
statusText = null,
isGenericStatus,
isWaitingForPermission,
wasAborted,
abortActive,
retryInfo,
showAbort,
onAbort,
showAbortStatus,
showAssistantStatus = true,
showTodos = true,
agentName,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const todosRecord = useDirectorySync((state) => state.todo);
const todos: TodoItem[] = React.useMemo(
() => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS),
[todosRecord, currentSessionId],
);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
// Filter out cancelled todos for display and keep original order.
// This prevents items from jumping around when status changes.
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
// Find the current active todo (first in_progress, or first pending)
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((t) => t.status === "in_progress") ||
visibleTodos.find((t) => t.status === "pending") ||
null
);
}, [visibleTodos]);
// Calculate progress
const progress = React.useMemo(() => {
const total = todos.filter((t) => t.status !== "cancelled").length;
const completed = todos.filter((t) => t.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((t) => t.status === "in_progress").length;
const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
const hasTodoContent = showTodos && hasActiveTodos;
const hasAssistantContent = showAssistantStatus && (
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus)
);
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
const hasContent = hasAssistantContent || hasTodoContent;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
// Abort button for mobile/vscode
const abortButton = showAbort && onAbort ? (
<button
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label="Stop generating"
>
<RiCloseCircleLine size={18} aria-hidden="true" />
</button>
) : null;
// Todo trigger button
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
>
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
{!isCompact && activeTodo ? (
<span className="typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">Tasks</span>
)}
<span className="typography-meta">
{statusSummary.active} active · {statusSummary.left} left
</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5" />
) : (
<RiArrowDownSLine className="h-3.5 w-3.5" />
)}
</button>
) : null;
// Don't render if nothing to show
if (!hasContent) {
return null;
}
return (
<div className="chat-column mb-1" style={{ containerType: "inline-size" }}>
<div className="flex items-center justify-between py-0.5 gap-2 h-[1.2rem]">
{/* Left: Abort status or Working placeholder */}
<div className="flex-1 flex items-center overflow-hidden min-w-0">
{showAssistantStatus && showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<RiCloseCircleLine size={16} aria-hidden="true" />
Aborted
</span>
</div>
) : showAssistantStatus && shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? "no-session"}
isWorking={isWorking}
statusText={statusText}
isGenericStatus={isGenericStatus}
isWaitingForPermission={isWaitingForPermission}
retryInfo={retryInfo}
agentName={agentName}
/>
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
<div className="relative -mr-3 flex items-center gap-2 flex-shrink-0" ref={popoverRef}>
{abortButton}
{todoTrigger}
{/* Popover dropdown */}
{isExpanded && hasActiveTodos && (
<div
style={{ maxWidth: "calc(100cqw - 4ch)" }}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px]",
"rounded-xl border border-border bg-background shadow-none",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="typography-ui-label text-muted-foreground">Tasks</span>
<span className="typography-meta text-muted-foreground">
{progress.completed}/{progress.total}
</span>
</div>
{/* Todo list */}
<div className="px-3 py-2 max-h-[200px] overflow-y-auto divide-y divide-border">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};