The composer rendered a second StatusRow instance carrying the pending-changes bar and the todos dropdown, so every restyle of the floating assistant-status chip (glass, placement, sizing) silently restyled the composer bar and its dropdown too — for the fourth time. StatusRow is now only the floating chip above the composer; the composer's own bar is a new ComposerStatusBar with the pre-glass layout, its own container name, and its own container-query classes (the anti-overlap rules that hide the long active todo under 38rem and the changed-files label under 30rem moved with it — losing them during the split let the two dropdown triggers overlap on mobile). The two components share no markup and no CSS hooks anymore.
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import React from "react";
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { cn } from "@/lib/utils";
|
|
import { useDirectorySync } from "@/sync/sync-context";
|
|
import type { Todo } from "@opencode-ai/sdk/v2/client";
|
|
import { useUIStore } from "@/stores/useUIStore";
|
|
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
|
|
import { isVSCodeRuntime } from "@/lib/desktop";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useI18n } from "@/lib/i18n";
|
|
|
|
// The bar that sits in the composer stack: pending-changes accessory, abort
|
|
// status, and the todos dropdown. Deliberately a separate component from
|
|
// StatusRow — that one is the floating assistant-status chip above the
|
|
// composer, and sharing markup meant every restyle of the chip (glass,
|
|
// placement) silently restyled this bar and its dropdown too.
|
|
|
|
type TodoItem = Todo & { id?: string };
|
|
|
|
const COMPOSER_STATUS_BAR_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "composer-status-bar" };
|
|
|
|
const statusConfig = {
|
|
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 = {
|
|
high: "text-[var(--status-warning)]",
|
|
medium: "text-muted-foreground",
|
|
low: "text-muted-foreground/70",
|
|
};
|
|
|
|
const priorityIcon = {
|
|
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true" />,
|
|
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true" />,
|
|
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true" />,
|
|
};
|
|
|
|
const statusLabelKey = {
|
|
in_progress: "chat.statusRow.todo.status.inProgress",
|
|
pending: "chat.statusRow.todo.status.pending",
|
|
completed: "chat.statusRow.todo.status.completed",
|
|
cancelled: "chat.statusRow.todo.status.cancelled",
|
|
};
|
|
|
|
const priorityLabelKey = {
|
|
high: "chat.statusRow.todo.priority.high",
|
|
medium: "chat.statusRow.todo.priority.medium",
|
|
low: "chat.statusRow.todo.priority.low",
|
|
};
|
|
|
|
// SAFETY: todo.status / todo.priority arrive from the SDK as open strings;
|
|
// lookups treat them as candidate keys and every call site falls back to a
|
|
// default entry when the value is outside the known set.
|
|
const knownStatus = (status: string) =>
|
|
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
|
|
status as keyof typeof statusConfig;
|
|
const knownPriority = (priority: string) =>
|
|
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
|
|
priority as keyof typeof priorityClassName;
|
|
|
|
const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
|
|
const { t } = useI18n();
|
|
const config = statusConfig[knownStatus(todo.status)] || statusConfig.pending;
|
|
// SAFETY: the label keys are literal members of the i18n dictionary; the
|
|
// lookup narrows an open SDK string with a known fallback, and t() accepts
|
|
// only the generated key union.
|
|
const statusKey = (statusLabelKey[knownStatus(todo.status)] ?? statusLabelKey.pending) as Parameters<typeof t>[0];
|
|
// SAFETY: same literal-member narrowing as statusKey above.
|
|
const priorityKey = (priorityLabelKey[knownPriority(todo.priority)] ?? priorityLabelKey.medium) as Parameters<typeof t>[0];
|
|
|
|
const statusIcon =
|
|
todo.status === "in_progress" ? (
|
|
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
|
|
) : todo.status === "completed" ? (
|
|
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
|
|
) : (
|
|
<Icon name="time" 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>
|
|
<TooltipTrigger asChild>
|
|
<span className="flex-shrink-0">{statusIcon}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="left" sideOffset={6}>
|
|
{t(statusKey)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<span className={cn("flex-1 typography-ui-label", config.textClassName)}>
|
|
{todo.content}
|
|
</span>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span
|
|
className={cn(
|
|
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
|
|
priorityClassName[knownPriority(todo.priority)] ?? priorityClassName.medium,
|
|
)}
|
|
>
|
|
{priorityIcon[knownPriority(todo.priority)] ?? priorityIcon.medium}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" sideOffset={6}>
|
|
{t(priorityKey)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const EMPTY_TODOS: TodoItem[] = [];
|
|
|
|
interface ComposerStatusBarProps {
|
|
showAbortStatus?: boolean;
|
|
showTodos?: boolean;
|
|
leftAccessory?: React.ReactNode;
|
|
}
|
|
|
|
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
|
|
showAbortStatus,
|
|
showTodos = true,
|
|
leftAccessory,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
const currentSessionDirectory = useSessionUIStore(
|
|
React.useCallback(
|
|
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
|
|
[currentSessionId],
|
|
),
|
|
);
|
|
const liveTodos = useDirectorySync(
|
|
React.useCallback(
|
|
(state) => {
|
|
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
|
|
return state.todo[currentSessionId] ?? EMPTY_TODOS;
|
|
},
|
|
[currentSessionId, showTodos],
|
|
),
|
|
);
|
|
const persistedSessionTodos = useTodosPersistStore(
|
|
React.useCallback(
|
|
(state) => (showTodos && currentSessionId && currentSessionDirectory
|
|
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
|
|
: undefined),
|
|
[currentSessionDirectory, currentSessionId, showTodos],
|
|
),
|
|
);
|
|
const todos: TodoItem[] = React.useMemo(() => {
|
|
if (!currentSessionId) return EMPTY_TODOS;
|
|
if (liveTodos.length > 0) return liveTodos;
|
|
return persistedSessionTodos ?? EMPTY_TODOS;
|
|
}, [liveTodos, persistedSessionTodos, currentSessionId]);
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
const isCompact = isMobile || isVSCodeRuntime();
|
|
|
|
const visibleTodos = React.useMemo(() => {
|
|
return todos.filter((todo) => todo.status !== "cancelled");
|
|
}, [todos]);
|
|
|
|
const activeTodo = React.useMemo(() => {
|
|
return (
|
|
visibleTodos.find((todo) => todo.status === "in_progress") ||
|
|
visibleTodos.find((todo) => todo.status === "pending") ||
|
|
null
|
|
);
|
|
}, [visibleTodos]);
|
|
|
|
const progress = React.useMemo(() => {
|
|
const total = todos.filter((todo) => todo.status !== "cancelled").length;
|
|
const completed = todos.filter((todo) => todo.status === "completed").length;
|
|
return { completed, total };
|
|
}, [todos]);
|
|
|
|
const statusSummary = React.useMemo(() => {
|
|
const active = visibleTodos.filter((todo) => todo.status === "in_progress").length;
|
|
const left = visibleTodos.filter((todo) => todo.status === "in_progress" || todo.status === "pending").length;
|
|
return { active, left };
|
|
}, [visibleTodos]);
|
|
|
|
const hasTodoContent = showTodos && statusSummary.left > 0;
|
|
const hasLeftAccessory = Boolean(leftAccessory);
|
|
const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory;
|
|
|
|
const popoverRef = React.useRef<HTMLDivElement>(null);
|
|
React.useEffect(() => {
|
|
if (!isExpanded) return;
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
// SAFETY: mousedown targets are DOM nodes; contains() only needs Node.
|
|
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);
|
|
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
|
|
active: statusSummary.active,
|
|
left: statusSummary.left,
|
|
});
|
|
|
|
const todoTrigger = hasTodoContent ? (
|
|
<button
|
|
type="button"
|
|
onClick={toggleExpanded}
|
|
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
|
|
aria-label={todoSummaryLabel}
|
|
title={todoSummaryLabel}
|
|
>
|
|
{!isCompact && activeTodo ? (
|
|
<span className="composer-status-bar__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
|
|
{activeTodo.content}
|
|
</span>
|
|
) : (
|
|
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
|
|
)}
|
|
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
|
|
<span className="flex items-center gap-0.5">
|
|
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
|
|
{statusSummary.active}
|
|
</span>
|
|
<span>·</span>
|
|
<span className="flex items-center gap-0.5">
|
|
<Icon name="time" className="h-3.5 w-3.5" />
|
|
{statusSummary.left}
|
|
</span>
|
|
</span>
|
|
{isExpanded ? (
|
|
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
) : null;
|
|
|
|
if (!hasContent) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="mb-2" style={COMPOSER_STATUS_BAR_CONTAINER_STYLE}>
|
|
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
|
|
{/* Left: abort status | pending-changes accessory */}
|
|
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
|
{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">
|
|
<Icon name="close-circle" aria-hidden="true" />
|
|
{t('chat.statusRow.aborted')}
|
|
</span>
|
|
</div>
|
|
) : leftAccessory ? (
|
|
leftAccessory
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Right: todos dropdown */}
|
|
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory && "pr-1.5")} ref={popoverRef}>
|
|
{todoTrigger}
|
|
|
|
{isExpanded && hasTodoContent && (
|
|
<div
|
|
style={{
|
|
maxWidth: "min(28rem, calc(100cqw - 4ch))",
|
|
backgroundColor: "var(--surface-elevated)",
|
|
color: "var(--surface-elevated-foreground)",
|
|
}}
|
|
className={cn(
|
|
"absolute right-0 bottom-full mb-1 z-50",
|
|
"w-max min-w-[200px] rounded-xl p-1",
|
|
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
|
|
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
|
|
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
|
"duration-150",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
|
|
<span>{t('chat.statusRow.tasksTitle')}</span>
|
|
<span className="typography-meta tabular-nums">
|
|
{progress.completed}/{progress.total}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="px-1 max-h-[200px] overflow-y-auto">
|
|
{visibleTodos.map((todo, index) => (
|
|
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|