refactor(chat): split the composer status bar from the assistant status chip
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.
This commit is contained in:
@@ -48,7 +48,7 @@ import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ModelControls } from './ModelControls';
|
import { ModelControls } from './ModelControls';
|
||||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||||
import { StatusRow } from './StatusRow';
|
import { ComposerStatusBar } from './ComposerStatusBar';
|
||||||
import { PendingChangesBar } from './PendingChangesBar';
|
import { PendingChangesBar } from './PendingChangesBar';
|
||||||
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
||||||
import { MobileAgentButton } from './MobileAgentButton';
|
import { MobileAgentButton } from './MobileAgentButton';
|
||||||
@@ -220,7 +220,7 @@ const MemoModelControls = React.memo(ModelControls);
|
|||||||
const MemoComposerDictation = React.memo(ComposerDictation);
|
const MemoComposerDictation = React.memo(ComposerDictation);
|
||||||
const MemoMobileAgentButton = React.memo(MobileAgentButton);
|
const MemoMobileAgentButton = React.memo(MobileAgentButton);
|
||||||
const MemoMobileModelButton = React.memo(MobileModelButton);
|
const MemoMobileModelButton = React.memo(MobileModelButton);
|
||||||
const MemoStatusRow = React.memo(StatusRow);
|
const MemoComposerStatusBar = React.memo(ComposerStatusBar);
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
@@ -2656,9 +2656,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
sessionId={currentSessionId}
|
sessionId={currentSessionId}
|
||||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||||
/>
|
/>
|
||||||
<MemoStatusRow
|
<MemoComposerStatusBar
|
||||||
showAbortStatus={showAbortStatus}
|
showAbortStatus={showAbortStatus}
|
||||||
showAssistantStatus={false}
|
|
||||||
showTodos={composerStatusExtrasEnabled}
|
showTodos={composerStatusExtrasEnabled}
|
||||||
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
|
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -107,7 +107,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
|
|||||||
>
|
>
|
||||||
<Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
|
<Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
|
||||||
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
|
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
|
||||||
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
|
<span className="composer-status-bar__changed-label min-w-0 typography-ui-label text-foreground truncate">
|
||||||
{t('chat.pendingChanges.changedInWorkspace')}
|
{t('chat.pendingChanges.changedInWorkspace')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
|
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
|
||||||
|
|||||||
@@ -1,123 +1,18 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
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";
|
|
||||||
|
|
||||||
// Compat aliases for old TodoItem shape
|
|
||||||
type TodoItem = Todo & { id?: string };
|
|
||||||
type TodoStatus = string;
|
|
||||||
type TodoPriority = string;
|
|
||||||
import { useUIStore } from "@/stores/useUIStore";
|
|
||||||
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
|
|
||||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
||||||
import { isVSCodeRuntime } from "@/lib/desktop";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
// The floating assistant-status chip that hovers above the composer while the
|
||||||
|
// agent works ("Claude is working…", abort notice). ONLY that. The composer's
|
||||||
|
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
|
||||||
|
// to share this component, and every restyle of this chip (glass, placement)
|
||||||
|
// silently dragged the composer bar and its dropdown along with it.
|
||||||
|
|
||||||
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
|
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
|
||||||
|
|
||||||
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: <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: Record<TodoStatus, string> = {
|
|
||||||
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: Record<TodoPriority, string> = {
|
|
||||||
high: "chat.statusRow.todo.priority.high",
|
|
||||||
medium: "chat.statusRow.todo.priority.medium",
|
|
||||||
low: "chat.statusRow.todo.priority.low",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TodoItemRowProps {
|
|
||||||
todo: TodoItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const config = statusConfig[todo.status] || statusConfig.pending;
|
|
||||||
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
|
|
||||||
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
|
|
||||||
|
|
||||||
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 as never)}
|
|
||||||
</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[todo.priority] ?? priorityClassName.medium
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{priorityIcon[todo.priority] ?? priorityIcon.medium}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" sideOffset={6}>
|
|
||||||
{t(priorityKey as never)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_TODOS: TodoItem[] = [];
|
|
||||||
|
|
||||||
interface StatusRowProps {
|
interface StatusRowProps {
|
||||||
// Working state
|
|
||||||
isWorking?: boolean;
|
isWorking?: boolean;
|
||||||
statusText?: string | null;
|
statusText?: string | null;
|
||||||
isGenericStatus?: boolean;
|
isGenericStatus?: boolean;
|
||||||
@@ -125,17 +20,10 @@ interface StatusRowProps {
|
|||||||
wasAborted?: boolean;
|
wasAborted?: boolean;
|
||||||
abortActive?: boolean;
|
abortActive?: boolean;
|
||||||
retryInfo?: { attempt?: number; next?: number } | null;
|
retryInfo?: { attempt?: number; next?: number } | null;
|
||||||
// Abort state (for mobile/vscode)
|
|
||||||
showAbort?: boolean;
|
|
||||||
onAbort?: () => void;
|
|
||||||
// Abort status display
|
|
||||||
showAbortStatus?: boolean;
|
showAbortStatus?: boolean;
|
||||||
showAssistantStatus?: boolean;
|
|
||||||
showTodos?: boolean;
|
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
modelName?: string | null;
|
modelName?: string | null;
|
||||||
providerId?: string | null;
|
providerId?: string | null;
|
||||||
leftAccessory?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatusRow: React.FC<StatusRowProps> = ({
|
export const StatusRow: React.FC<StatusRowProps> = ({
|
||||||
@@ -146,160 +34,17 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
wasAborted,
|
wasAborted,
|
||||||
abortActive,
|
abortActive,
|
||||||
retryInfo,
|
retryInfo,
|
||||||
showAbort,
|
|
||||||
onAbort,
|
|
||||||
showAbortStatus,
|
showAbortStatus,
|
||||||
showAssistantStatus = true,
|
|
||||||
showTodos = true,
|
|
||||||
agentName,
|
agentName,
|
||||||
modelName,
|
modelName,
|
||||||
providerId,
|
providerId,
|
||||||
leftAccessory,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
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();
|
|
||||||
|
|
||||||
// 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 hasTodoContent = showTodos && statusSummary.left > 0;
|
|
||||||
const hasAssistantContent = showAssistantStatus && (
|
|
||||||
isWorking ||
|
|
||||||
Boolean(wasAborted) ||
|
|
||||||
Boolean(showAbortStatus)
|
|
||||||
);
|
|
||||||
const hasLeftAccessory = Boolean(leftAccessory);
|
|
||||||
// Original logic from ChatInput
|
|
||||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
||||||
|
const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus);
|
||||||
|
|
||||||
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
|
|
||||||
active: statusSummary.active,
|
|
||||||
left: statusSummary.left,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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={t('chat.statusRow.actions.stopGeneratingAria')}
|
|
||||||
>
|
|
||||||
<Icon name="close-circle" 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"
|
|
||||||
aria-label={todoSummaryLabel}
|
|
||||||
title={todoSummaryLabel}
|
|
||||||
>
|
|
||||||
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
|
|
||||||
{!isCompact && activeTodo ? (
|
|
||||||
<span className="status-row__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;
|
|
||||||
|
|
||||||
// Don't render if nothing to show
|
|
||||||
if (!hasContent) {
|
if (!hasContent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -308,9 +53,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
<div
|
<div
|
||||||
// The row renders inside the composer-anchored overlay, which owns the
|
// The row renders inside the composer-anchored overlay, which owns the
|
||||||
// distance to the input and the horizontal column (the same ones the
|
// distance to the input and the horizontal column (the same ones the
|
||||||
// scroll-to-bottom pill uses). The in-list morph offsets (mb-6 and the
|
// scroll-to-bottom pill uses).
|
||||||
// message column) belonged to the old footer placement and pushed the
|
|
||||||
// row up and right relative to the pill.
|
|
||||||
style={STATUS_ROW_CONTAINER_STYLE}
|
style={STATUS_ROW_CONTAINER_STYLE}
|
||||||
>
|
>
|
||||||
{/* h-8 matches the turn footer's real row height: its h-8 action
|
{/* h-8 matches the turn footer's real row height: its h-8 action
|
||||||
@@ -318,19 +61,16 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
{/* The glass chip lives here, not on the container: the root above is
|
{/* The glass chip lives here, not on the container: the root above is
|
||||||
an inline-size query container, whose width ignores its children —
|
an inline-size query container, whose width ignores its children —
|
||||||
a shrink-to-fit wrapper around it always collapsed to zero. */}
|
a shrink-to-fit wrapper around it always collapsed to zero. */}
|
||||||
<div className={cn("oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3", hasLeftAccessory && "px-0.5")}>
|
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
|
||||||
{/* Left: Abort status | Working placeholder | leftAccessory. Sized by
|
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
|
||||||
its content: the row now lives in a shrink-to-fit glass chip, and
|
{showAbortStatus ? (
|
||||||
a flex-1 (basis 0) here collapsed the chip to zero width. */}
|
|
||||||
<div className={cn("flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
|
|
||||||
{showAssistantStatus && showAbortStatus ? (
|
|
||||||
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
|
<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">
|
<span className="flex items-center gap-1.5 typography-ui-label">
|
||||||
<Icon name="close-circle" aria-hidden="true"/>
|
<Icon name="close-circle" aria-hidden="true"/>
|
||||||
{t('chat.statusRow.aborted')}
|
{t('chat.statusRow.aborted')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : showAssistantStatus && shouldRenderPlaceholder ? (
|
) : shouldRenderPlaceholder ? (
|
||||||
<WorkingPlaceholder
|
<WorkingPlaceholder
|
||||||
key={currentSessionId ?? "no-session"}
|
key={currentSessionId ?? "no-session"}
|
||||||
isWorking={isWorking}
|
isWorking={isWorking}
|
||||||
@@ -342,50 +82,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
modelName={modelName}
|
modelName={modelName}
|
||||||
providerId={providerId}
|
providerId={providerId}
|
||||||
/>
|
/>
|
||||||
) : leftAccessory ? (
|
|
||||||
leftAccessory
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Abort (mobile only) + Todo */}
|
|
||||||
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
|
|
||||||
{abortButton}
|
|
||||||
{todoTrigger}
|
|
||||||
|
|
||||||
{/* Popover dropdown */}
|
|
||||||
{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"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Todo list */}
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
|||||||
wasAborted={wasAborted || working.wasAborted}
|
wasAborted={wasAborted || working.wasAborted}
|
||||||
abortActive={wasAborted || working.abortActive}
|
abortActive={wasAborted || working.abortActive}
|
||||||
retryInfo={working.retryInfo}
|
retryInfo={working.retryInfo}
|
||||||
showAssistantStatus
|
|
||||||
showTodos={false}
|
|
||||||
agentName={currentAgentName}
|
agentName={currentAgentName}
|
||||||
modelName={modelDisplayName}
|
modelName={modelDisplayName}
|
||||||
providerId={activeModel?.providerId ?? null}
|
providerId={activeModel?.providerId ?? null}
|
||||||
|
|||||||
@@ -893,15 +893,15 @@ html:not(.dark) .chat-scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the long active todo before it can collide with the changed-files summary. */
|
/* Hide the long active todo before it can collide with the changed-files summary. */
|
||||||
@container status-row (max-width: 38rem) {
|
@container composer-status-bar (max-width: 38rem) {
|
||||||
.status-row__active-todo {
|
.composer-status-bar__active-todo {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the secondary changed-files label on narrow mobile layouts. */
|
/* Hide the secondary changed-files label on narrow mobile layouts. */
|
||||||
@container status-row (max-width: 30rem) {
|
@container composer-status-bar (max-width: 30rem) {
|
||||||
.status-row__changed-label {
|
.composer-status-bar__changed-label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user