feat(webui): polish sidebar and session transitions (#5393)

This commit is contained in:
chengyongru
2026-08-14 17:03:11 +08:00
committed by GitHub
parent 057c5e849b
commit 221e8a4e4a
17 changed files with 1152 additions and 560 deletions
+263 -76
View File
@@ -7,11 +7,13 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import type { MouseEvent as ReactMouseEvent, ReactElement } from "react";
import { import {
Archive, Archive,
ArchiveRestore, ArchiveRestore,
ChevronDown, ChevronDown,
Folder, Folder,
FolderTree,
ListChecks, ListChecks,
MessageCircleDashed, MessageCircleDashed,
MoreHorizontal, MoreHorizontal,
@@ -40,6 +42,12 @@ import {
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model"; import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight"; import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight";
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format"; import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
@@ -64,6 +72,48 @@ const VISIBLE_SESSIONS_INCREMENT = 160;
const ACTION_MENU_CONTENT_CLASS = "w-[11rem] min-w-[11rem] whitespace-nowrap"; const ACTION_MENU_CONTENT_CLASS = "w-[11rem] min-w-[11rem] whitespace-nowrap";
const COLLAPSED_PANE_GROUPS_STORAGE_KEY = "nanobot-webui.collapsed-pane-groups.v1"; const COLLAPSED_PANE_GROUPS_STORAGE_KEY = "nanobot-webui.collapsed-pane-groups.v1";
interface SidebarActionMenuController {
openId: string | null;
onOpenChange: (id: string, open: boolean) => void;
openFromContextMenu: (event: ReactMouseEvent<HTMLElement>, id: string) => void;
}
function SidebarItemTooltip({
label,
children,
}: {
label: string;
children: ReactElement;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-80 break-words">
{label}
</TooltipContent>
</Tooltip>
);
}
function SidebarSelectionTrack({
active,
}: {
active: boolean;
}) {
return (
<span
data-sidebar-selection-track
data-active={active ? "true" : "false"}
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 h-0.5 origin-left rounded-full bg-current",
"transition-transform duration-200 ease-out motion-reduce:transition-none",
active ? "scale-x-100" : "scale-x-0",
)}
/>
);
}
function readCollapsedPaneGroups(): Set<string> { function readCollapsedPaneGroups(): Set<string> {
try { try {
const value = JSON.parse(window.localStorage.getItem( const value = JSON.parse(window.localStorage.getItem(
@@ -200,13 +250,14 @@ export const ChatList = memo(function ChatList({
}: ChatListProps) { }: ChatListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const tabRowRefs = useRef(new Map<string, HTMLLIElement>()); const layoutRowRefs = useRef(new Map<string, HTMLElement>());
const pendingTabRectsRef = useRef<Map<string, DOMRect> | null>(null); const pendingLayoutRectsRef = useRef<Map<string, DOMRect> | null>(null);
const tabLayoutAnimationsRef = useRef(new Map<string, Animation>()); const layoutAnimationsRef = useRef(new Map<string, Animation>());
const [collapsedPaneGroups, setCollapsedPaneGroups] = useState<Set<string>>( const [collapsedPaneGroups, setCollapsedPaneGroups] = useState<Set<string>>(
readCollapsedPaneGroups, readCollapsedPaneGroups,
); );
const [deleteSelectionMode, setDeleteSelectionMode] = useState(false); const [deleteSelectionMode, setDeleteSelectionMode] = useState(false);
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>( const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
() => new Set(), () => new Set(),
); );
@@ -291,6 +342,26 @@ export const ChatList = memo(function ChatList({
const pinnedPanes = useMemo(() => new Set(pinnedPaneKeys), [pinnedPaneKeys]); const pinnedPanes = useMemo(() => new Set(pinnedPaneKeys), [pinnedPaneKeys]);
const archivedPanes = useMemo(() => new Set(archivedPaneKeys), [archivedPaneKeys]); const archivedPanes = useMemo(() => new Set(archivedPaneKeys), [archivedPaneKeys]);
const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount); const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
const handleActionMenuOpenChange = useCallback((id: string, open: boolean) => {
setOpenActionMenuId((current) => {
if (open) return id;
return current === id ? null : current;
});
}, []);
const openActionMenuFromContextMenu = useCallback((
event: ReactMouseEvent<HTMLElement>,
id: string,
) => {
event.preventDefault();
event.stopPropagation();
if (deleteSelectionMode) return;
setOpenActionMenuId(id);
}, [deleteSelectionMode]);
const actionMenus: SidebarActionMenuController = {
openId: openActionMenuId,
onOpenChange: handleActionMenuOpenChange,
openFromContextMenu: openActionMenuFromContextMenu,
};
useEffect(() => { useEffect(() => {
setVisibleLimit(INITIAL_VISIBLE_SESSIONS); setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
@@ -298,6 +369,7 @@ export const ChatList = memo(function ChatList({
useEffect(() => { useEffect(() => {
if (!deleteSelectionMode) return; if (!deleteSelectionMode) return;
setOpenActionMenuId(null);
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
setDeleteSelectionMode(false); setDeleteSelectionMode(false);
@@ -324,41 +396,47 @@ export const ChatList = memo(function ChatList({
}); });
}, [loading, paneGroups]); }, [loading, paneGroups]);
const measureTabRows = useCallback(() => { const measureLayoutRows = useCallback(() => {
const rects = new Map<string, DOMRect>(); const rects = new Map<string, DOMRect>();
for (const [key, row] of tabRowRefs.current) { for (const [key, row] of layoutRowRefs.current) {
rects.set(key, row.getBoundingClientRect()); rects.set(key, row.getBoundingClientRect());
} }
return rects; return rects;
}, []); }, []);
const captureTabLayout = useCallback(() => { const captureLayout = useCallback(() => {
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel(); for (const animation of layoutAnimationsRef.current.values()) animation.cancel();
tabLayoutAnimationsRef.current.clear(); layoutAnimationsRef.current.clear();
pendingTabRectsRef.current = measureTabRows(); pendingLayoutRectsRef.current = measureLayoutRows();
}, [measureTabRows]); }, [measureLayoutRows]);
const togglePaneGroup = useCallback((key: string) => { const togglePaneGroup = useCallback((key: string) => {
captureTabLayout(); captureLayout();
setCollapsedPaneGroups((current) => { setCollapsedPaneGroups((current) => {
const next = new Set(current); const next = new Set(current);
if (next.has(key)) next.delete(key); if (next.has(key)) next.delete(key);
else next.add(key); else next.add(key);
return next; return next;
}); });
}, [captureTabLayout]); }, [captureLayout]);
const toggleProjectGroup = useCallback((key: string) => {
if (!onToggleGroup) return;
captureLayout();
onToggleGroup(key);
}, [captureLayout, onToggleGroup]);
useLayoutEffect(() => { useLayoutEffect(() => {
const previousRects = pendingTabRectsRef.current; const previousRects = pendingLayoutRectsRef.current;
if (!previousRects) return; if (!previousRects) return;
pendingTabRectsRef.current = null; pendingLayoutRectsRef.current = null;
const nextRects = measureTabRows(); const nextRects = measureLayoutRows();
const reduceMotion = typeof window.matchMedia === "function" const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches; && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) return; if (reduceMotion) return;
for (const [key, nextRect] of nextRects) { for (const [key, nextRect] of nextRects) {
const previousRect = previousRects.get(key); const previousRect = previousRects.get(key);
const row = tabRowRefs.current.get(key); const row = layoutRowRefs.current.get(key);
if (!previousRect || !row || typeof row.animate !== "function") continue; if (!previousRect || !row || typeof row.animate !== "function") continue;
const deltaY = previousRect.top - nextRect.top; const deltaY = previousRect.top - nextRect.top;
if (Math.abs(deltaY) < 0.5) continue; if (Math.abs(deltaY) < 0.5) continue;
@@ -372,17 +450,17 @@ export const ChatList = memo(function ChatList({
easing: "cubic-bezier(0.2, 0, 0, 1)", easing: "cubic-bezier(0.2, 0, 0, 1)",
}, },
); );
tabLayoutAnimationsRef.current.set(key, animation); layoutAnimationsRef.current.set(key, animation);
animation.addEventListener("finish", () => { animation.addEventListener("finish", () => {
if (tabLayoutAnimationsRef.current.get(key) === animation) { if (layoutAnimationsRef.current.get(key) === animation) {
tabLayoutAnimationsRef.current.delete(key); layoutAnimationsRef.current.delete(key);
} }
}, { once: true }); }, { once: true });
} }
}, [collapsedPaneGroups, measureTabRows]); }, [collapsedGroups, collapsedPaneGroups, measureLayoutRows]);
useEffect(() => () => { useEffect(() => () => {
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel(); for (const animation of layoutAnimationsRef.current.values()) animation.cancel();
}, []); }, []);
if (loading && sessions.length === 0 && temporarySessions.length === 0) { if (loading && sessions.length === 0 && temporarySessions.length === 0) {
@@ -441,6 +519,7 @@ export const ChatList = memo(function ChatList({
closeDeleteSelection(); closeDeleteSelection();
}; };
return ( return (
<TooltipProvider delayDuration={650} skipDelayDuration={120}>
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent"> <div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<div <div
data-chat-list-content data-chat-list-content
@@ -465,6 +544,8 @@ export const ChatList = memo(function ChatList({
); );
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length); const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT; const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
const projectCollapsed = group.kind === "project"
&& Boolean(collapsedGroups[group.id]);
return ( return (
<section key={group.id} aria-label={group.label} className="relative z-[1]"> <section key={group.id} aria-label={group.label} className="relative z-[1]">
{index === firstProjectGroupIndex ? ( {index === firstProjectGroupIndex ? (
@@ -472,12 +553,23 @@ export const ChatList = memo(function ChatList({
{labels.projects} {labels.projects}
</div> </div>
) : null} ) : null}
<div>
<div
ref={(element) => {
const key = `group:${group.id}`;
if (element) layoutRowRefs.current.set(key, element);
else layoutRowRefs.current.delete(key);
}}
data-sidebar-group-header={group.id}
>
{group.kind === "project" ? ( {group.kind === "project" ? (
<ProjectGroupHeader <ProjectGroupHeader
label={group.label} label={group.label}
path={group.projectPath} path={group.projectPath}
collapsed={Boolean(collapsedGroups[group.id])} actionMenuId={`project:${group.id}`}
onToggle={() => onToggleGroup?.(group.id)} actionMenus={actionMenus}
collapsed={projectCollapsed}
onToggle={() => toggleProjectGroup(group.id)}
onRequestRename={ onRequestRename={
group.projectKey && onRequestRenameProject group.projectKey && onRequestRenameProject
? () => onRequestRenameProject(group.projectKey ?? "", group.label) ? () => onRequestRenameProject(group.projectKey ?? "", group.label)
@@ -494,7 +586,15 @@ export const ChatList = memo(function ChatList({
) : ( ) : (
<ChatsGroupHeader label={group.label} /> <ChatsGroupHeader label={group.label} />
)} )}
{group.kind === "project" && collapsedGroups[group.id] ? null : ( </div>
{projectCollapsed ? null : (
<div
data-sidebar-project-surface={group.kind === "project" ? "true" : undefined}
className={cn(
group.kind === "project"
&& "rounded-es-[16px] border-s-2 border-sidebar-foreground/10 pb-1",
)}
>
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{visibleSessions.map((s) => { {visibleSessions.map((s) => {
const topicActive = s.key === activeKey; const topicActive = s.key === activeKey;
@@ -528,8 +628,8 @@ export const ChatList = memo(function ChatList({
<li <li
key={s.key} key={s.key}
ref={(element) => { ref={(element) => {
if (element) tabRowRefs.current.set(s.key, element); if (element) layoutRowRefs.current.set(s.key, element);
else tabRowRefs.current.delete(s.key); else layoutRowRefs.current.delete(s.key);
}} }}
data-sidebar-tab-group="true" data-sidebar-tab-group="true"
data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined} data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined}
@@ -538,15 +638,16 @@ export const ChatList = memo(function ChatList({
<div <div
data-workbench-tab-surface data-workbench-tab-surface
className={cn( className={cn(
"-mx-2 min-w-0 bg-sidebar-foreground/[0.045] px-3 dark:bg-white/[0.07]", "min-w-0",
paneGroupCollapsed ? "py-0" : "py-1", projectMode && "-ms-0.5",
deleteSelectionMode && (tabSelected || tabPartiallySelected) deleteSelectionMode && (tabSelected || tabPartiallySelected)
&& "ring-1 ring-inset ring-sidebar-foreground/25", && "ring-1 ring-inset ring-sidebar-foreground/25",
)} )}
> >
<div className={cn("min-w-0", projectMode && "ps-7")}>
<WorkbenchTabHeader <WorkbenchTabHeader
title={title} title={title}
actionMenuId={`tab:${resolvedPaneGroup.tabKey}`}
actionMenus={actionMenus}
controlsId={paneGroupId} controlsId={paneGroupId}
collapsed={paneGroupCollapsed} collapsed={paneGroupCollapsed}
deleteSelectionMode={deleteSelectionMode} deleteSelectionMode={deleteSelectionMode}
@@ -589,10 +690,10 @@ export const ChatList = memo(function ChatList({
onToggleDeleteSelection={toggleDeleteSelection} onToggleDeleteSelection={toggleDeleteSelection}
onBeginDeleteSelection={beginDeleteSelection} onBeginDeleteSelection={beginDeleteSelection}
actionMenuPortalContainer={actionMenuPortalContainer} actionMenuPortalContainer={actionMenuPortalContainer}
actionMenus={actionMenus}
/> />
) : null} ) : null}
</div> </div>
</div>
</li> </li>
); );
} }
@@ -618,29 +719,34 @@ export const ChatList = memo(function ChatList({
? "updated" ? "updated"
: null; : null;
const canDragSession = !topicActive && !deleteSelectionMode; const canDragSession = !topicActive && !deleteSelectionMode;
const actionMenuId = `session:${s.key}`;
return ( return (
<li <li
key={s.key} key={s.key}
ref={(element) => { ref={(element) => {
if (element) tabRowRefs.current.set(s.key, element); if (element) layoutRowRefs.current.set(s.key, element);
else tabRowRefs.current.delete(s.key); else layoutRowRefs.current.delete(s.key);
}} }}
className="relative min-w-0" className="relative min-w-0"
> >
<div <div
data-chat-row={s.key} data-chat-row={s.key}
data-sidebar-tab={s.key} data-sidebar-tab={s.key}
onContextMenu={(event) => (
actionMenus.openFromContextMenu(event, actionMenuId)
)}
className={cn( className={cn(
"group flex min-w-0 max-w-full items-center gap-1 rounded-[0.65rem] px-2 text-[13px]", "group flex min-w-0 max-w-full items-center gap-1 rounded-[0.65rem] px-2 text-[13px]",
SIDEBAR_SELECTION_ITEM_CLASS, SIDEBAR_SELECTION_ITEM_CLASS,
compact ? "min-h-7" : "min-h-8", compact ? "min-h-7" : "min-h-8",
topicActive topicActive
? "bg-sidebar-selected text-sidebar-accent-foreground" ? "text-sidebar-foreground"
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.075] hover:text-sidebar-foreground dark:hover:bg-white/[0.09]", : "text-sidebar-foreground/82 hover:text-sidebar-foreground",
deleteSelectionMode && (tabSelected || tabPartiallySelected) deleteSelectionMode && (tabSelected || tabPartiallySelected)
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground", && "bg-sidebar-accent/55 text-sidebar-accent-foreground",
)} )}
> >
<SidebarItemTooltip label={tooltipTitle}>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -661,13 +767,11 @@ export const ChatList = memo(function ChatList({
onDragEnd={clearDraggedSession} onDragEnd={clearDraggedSession}
aria-current={topicActive ? "page" : undefined} aria-current={topicActive ? "page" : undefined}
aria-pressed={deleteSelectionMode ? tabSelected : undefined} aria-pressed={deleteSelectionMode ? tabSelected : undefined}
title={tooltipTitle}
className={cn( className={cn(
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left", "flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left",
canDragSession && "cursor-grab active:cursor-grabbing", canDragSession && "cursor-grab active:cursor-grabbing",
deleteSelectionMode && "cursor-default", deleteSelectionMode && "cursor-default",
compact ? "py-1" : "py-1.5", compact ? "py-1" : "py-1.5",
projectMode && "pl-7",
)} )}
> >
{deleteSelectionMode ? ( {deleteSelectionMode ? (
@@ -678,23 +782,25 @@ export const ChatList = memo(function ChatList({
) : null} ) : null}
<span className="min-w-0 flex-1 overflow-hidden"> <span className="min-w-0 flex-1 overflow-hidden">
{projectMode ? ( {projectMode ? (
<span className="flex w-full min-w-0 items-baseline gap-2"> <span className="relative flex w-full min-w-0 items-baseline gap-2">
<span className="min-w-0 flex-1 truncate font-medium leading-5"> <span className="min-w-0 flex-1 truncate font-medium leading-5">
{title} {title}
</span> </span>
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null} {isPinned ? <PinnedChatIndicator /> : null}
{timestamp ? ( {timestamp ? (
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58"> <span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
{timestamp} {timestamp}
</span> </span>
) : null} ) : null}
<SidebarSelectionTrack active={topicActive} />
</span> </span>
) : ( ) : (
<span className="flex w-full min-w-0 items-center gap-1.5"> <span className="relative flex w-full min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-medium leading-5"> <span className="min-w-0 flex-1 truncate font-medium leading-5">
{title} {title}
</span> </span>
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null} {isPinned ? <PinnedChatIndicator /> : null}
<SidebarSelectionTrack active={topicActive} />
</span> </span>
)} )}
{showPreview ? ( {showPreview ? (
@@ -709,9 +815,16 @@ export const ChatList = memo(function ChatList({
) : null} ) : null}
</span> </span>
</button> </button>
</SidebarItemTooltip>
<SessionActivityIndicator state={activityState} /> <SessionActivityIndicator state={activityState} />
{!deleteSelectionMode ? ( {!deleteSelectionMode ? (
<DropdownMenu modal={false}> <DropdownMenu
modal={false}
open={actionMenus.openId === actionMenuId}
onOpenChange={(open) => (
actionMenus.onOpenChange(actionMenuId, open)
)}
>
<DropdownMenuTrigger <DropdownMenuTrigger
className={cn( className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-0 transition-opacity", "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-0 transition-opacity",
@@ -789,7 +902,6 @@ export const ChatList = memo(function ChatList({
); );
})} })}
</ul> </ul>
)}
{foldableChatsGroup && canToggleFold ? ( {foldableChatsGroup && canToggleFold ? (
<ChatsFoldFooter <ChatsFoldFooter
folded={foldedChatsGroup} folded={foldedChatsGroup}
@@ -797,6 +909,9 @@ export const ChatList = memo(function ChatList({
onToggle={() => onToggleGroup?.(group.id)} onToggle={() => onToggleGroup?.(group.id)}
/> />
) : null} ) : null}
</div>
)}
</div>
</section> </section>
); );
})} })}
@@ -849,11 +964,14 @@ export const ChatList = memo(function ChatList({
) : null} ) : null}
</div> </div>
</div> </div>
</TooltipProvider>
); );
}); });
function WorkbenchTabHeader({ function WorkbenchTabHeader({
title, title,
actionMenuId,
actionMenus,
controlsId, controlsId,
collapsed, collapsed,
deleteSelectionMode, deleteSelectionMode,
@@ -867,6 +985,8 @@ function WorkbenchTabHeader({
actionMenuPortalContainer, actionMenuPortalContainer,
}: { }: {
title: string; title: string;
actionMenuId: string;
actionMenus: SidebarActionMenuController;
controlsId: string; controlsId: string;
collapsed: boolean; collapsed: boolean;
deleteSelectionMode: boolean; deleteSelectionMode: boolean;
@@ -888,11 +1008,13 @@ function WorkbenchTabHeader({
return ( return (
<div <div
data-workbench-tab data-workbench-tab
onContextMenu={(event) => actionMenus.openFromContextMenu(event, actionMenuId)}
className={cn( className={cn(
"group/tab flex min-w-0 items-center gap-0.5 rounded-[0.65rem] px-1.5 text-sidebar-foreground/85", "group/tab flex min-w-0 items-center gap-0.5 rounded-[0.65rem] px-1.5 text-sidebar-foreground/85",
collapsed ? "min-h-6" : "min-h-7", collapsed ? "min-h-6" : "min-h-7",
)} )}
> >
<SidebarItemTooltip label={title}>
<button <button
type="button" type="button"
onClick={deleteSelectionMode ? onToggleSelection : onToggle} onClick={deleteSelectionMode ? onToggleSelection : onToggle}
@@ -901,7 +1023,6 @@ function WorkbenchTabHeader({
aria-expanded={deleteSelectionMode ? undefined : !collapsed} aria-expanded={deleteSelectionMode ? undefined : !collapsed}
aria-controls={deleteSelectionMode ? undefined : controlsId} aria-controls={deleteSelectionMode ? undefined : controlsId}
aria-pressed={deleteSelectionMode ? selected : undefined} aria-pressed={deleteSelectionMode ? selected : undefined}
title={title}
className={cn( className={cn(
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden px-0.5 py-1 text-left", "flex min-w-0 flex-1 items-center gap-2 overflow-hidden px-0.5 py-1 text-left",
"text-[12.5px] font-normal leading-5", "text-[12.5px] font-normal leading-5",
@@ -911,11 +1032,21 @@ function WorkbenchTabHeader({
{deleteSelectionMode ? ( {deleteSelectionMode ? (
<SelectionIndicator checked={selected} partial={partiallySelected} /> <SelectionIndicator checked={selected} partial={partiallySelected} />
) : null} ) : null}
<FolderTree
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70"
strokeWidth={1.75}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate">{title}</span> <span className="min-w-0 flex-1 truncate">{title}</span>
</button> </button>
</SidebarItemTooltip>
{!deleteSelectionMode ? ( {!deleteSelectionMode ? (
<> <>
<DropdownMenu modal={false}> <DropdownMenu
modal={false}
open={actionMenus.openId === actionMenuId}
onOpenChange={(open) => actionMenus.onOpenChange(actionMenuId, open)}
>
<DropdownMenuTrigger <DropdownMenuTrigger
className={cn( className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md", "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
@@ -957,12 +1088,12 @@ function WorkbenchTabHeader({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<SidebarItemTooltip label={disclosureLabel}>
<button <button
type="button" type="button"
aria-expanded={!collapsed} aria-expanded={!collapsed}
aria-controls={controlsId} aria-controls={controlsId}
aria-label={disclosureLabel} aria-label={disclosureLabel}
title={disclosureLabel}
onClick={onToggle} onClick={onToggle}
className={cn( className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md", "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
@@ -976,10 +1107,11 @@ function WorkbenchTabHeader({
aria-hidden aria-hidden
className={cn( className={cn(
"h-3.5 w-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none", "h-3.5 w-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none",
!collapsed && "rotate-180", collapsed && "rotate-90",
)} )}
/> />
</button> </button>
</SidebarItemTooltip>
</> </>
) : null} ) : null}
</div> </div>
@@ -1009,6 +1141,7 @@ function ActivePaneRows({
onToggleDeleteSelection, onToggleDeleteSelection,
onBeginDeleteSelection, onBeginDeleteSelection,
actionMenuPortalContainer, actionMenuPortalContainer,
actionMenus,
}: { }: {
id: string; id: string;
group: SidebarPaneGroup; group: SidebarPaneGroup;
@@ -1035,6 +1168,7 @@ function ActivePaneRows({
onToggleDeleteSelection: (keys: string[]) => void; onToggleDeleteSelection: (keys: string[]) => void;
onBeginDeleteSelection: (keys: string[]) => void; onBeginDeleteSelection: (keys: string[]) => void;
actionMenuPortalContainer?: HTMLElement | null; actionMenuPortalContainer?: HTMLElement | null;
actionMenus: SidebarActionMenuController;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const panes = group.panes; const panes = group.panes;
@@ -1045,7 +1179,7 @@ function ActivePaneRows({
defaultValue: "Panes in {{title}}", defaultValue: "Panes in {{title}}",
title: tabTitle, title: tabTitle,
})} })}
className="mt-0.5 space-y-0.5" className="mt-0.5 space-y-0.5 rounded-es-[14px] border-s-2 border-sidebar-foreground/25 pb-1"
> >
{panes.map((pane) => { {panes.map((pane) => {
const active = tabActive && pane.key === group.activePaneKey; const active = tabActive && pane.key === group.activePaneKey;
@@ -1062,6 +1196,7 @@ function ActivePaneRows({
const isPinned = pinned.has(pane.key); const isPinned = pinned.has(pane.key);
const isArchived = archived.has(pane.key); const isArchived = archived.has(pane.key);
const canDragSession = !active && !deleteSelectionMode; const canDragSession = !active && !deleteSelectionMode;
const actionMenuId = `pane:${pane.key}`;
return ( return (
<li <li
@@ -1071,17 +1206,21 @@ function ActivePaneRows({
<div <div
data-chat-row={pane.key} data-chat-row={pane.key}
data-sidebar-pane={pane.key} data-sidebar-pane={pane.key}
onContextMenu={(event) => (
actionMenus.openFromContextMenu(event, actionMenuId)
)}
className={cn( className={cn(
"group/pane flex min-w-0 max-w-full items-center gap-1 rounded-[0.65rem] px-2 text-[13px]", "group/pane flex min-w-0 max-w-full items-center gap-1 rounded-[0.65rem] px-2 text-[13px]",
SIDEBAR_SELECTION_ITEM_CLASS, SIDEBAR_SELECTION_ITEM_CLASS,
compact ? "min-h-7" : "min-h-8", compact ? "min-h-7" : "min-h-8",
active active
? "bg-sidebar-selected text-sidebar-accent-foreground" ? "text-sidebar-foreground"
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.075] hover:text-sidebar-foreground dark:hover:bg-white/[0.09]", : "text-sidebar-foreground/82 hover:text-sidebar-foreground",
deleteSelectionMode && selected deleteSelectionMode && selected
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground", && "bg-sidebar-accent/55 text-sidebar-accent-foreground",
)} )}
> >
<SidebarItemTooltip label={pane.title}>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -1102,7 +1241,6 @@ function ActivePaneRows({
onDragEnd={clearDraggedSession} onDragEnd={clearDraggedSession}
aria-current={active ? "true" : undefined} aria-current={active ? "true" : undefined}
aria-pressed={deleteSelectionMode ? selected : undefined} aria-pressed={deleteSelectionMode ? selected : undefined}
title={pane.title}
className={cn( className={cn(
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left font-medium leading-5", "flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left font-medium leading-5",
canDragSession && "cursor-grab active:cursor-grabbing", canDragSession && "cursor-grab active:cursor-grabbing",
@@ -1113,11 +1251,19 @@ function ActivePaneRows({
{deleteSelectionMode ? ( {deleteSelectionMode ? (
<SelectionIndicator checked={selected} partial={false} /> <SelectionIndicator checked={selected} partial={false} />
) : null} ) : null}
<span className="relative flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<span className="min-w-0 flex-1 truncate">{pane.title}</span> <span className="min-w-0 flex-1 truncate">{pane.title}</span>
{isPinned ? <PinnedChatIndicator label={t("chat.groups.pinned")} /> : null} {isPinned ? <PinnedChatIndicator /> : null}
<SidebarSelectionTrack active={active} />
</span>
</button> </button>
</SidebarItemTooltip>
<SessionActivityIndicator state={activityState} /> <SessionActivityIndicator state={activityState} />
{!deleteSelectionMode ? <DropdownMenu modal={false}> {!deleteSelectionMode ? <DropdownMenu
modal={false}
open={actionMenus.openId === actionMenuId}
onOpenChange={(open) => actionMenus.onOpenChange(actionMenuId, open)}
>
<DropdownMenuTrigger <DropdownMenuTrigger
className={cn( className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity", "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity",
@@ -1282,11 +1428,11 @@ function TemporaryChatSection({
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]", : "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
)} )}
> >
<SidebarItemTooltip label={title}>
<button <button
type="button" type="button"
onClick={() => onSelect(session.key)} onClick={() => onSelect(session.key)}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
title={title}
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-1.5 text-left" className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-1.5 text-left"
> >
<MessageCircleDashed <MessageCircleDashed
@@ -1297,6 +1443,7 @@ function TemporaryChatSection({
{title} {title}
</span> </span>
</button> </button>
</SidebarItemTooltip>
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} /> <SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
{onClose ? ( {onClose ? (
<button <button
@@ -1320,6 +1467,8 @@ function TemporaryChatSection({
function ProjectGroupHeader({ function ProjectGroupHeader({
label, label,
path, path,
actionMenuId,
actionMenus,
collapsed, collapsed,
onToggle, onToggle,
onRequestRename, onRequestRename,
@@ -1329,6 +1478,8 @@ function ProjectGroupHeader({
}: { }: {
label: string; label: string;
path?: string; path?: string;
actionMenuId: string;
actionMenus: SidebarActionMenuController;
collapsed: boolean; collapsed: boolean;
onToggle: () => void; onToggle: () => void;
onRequestRename?: () => void; onRequestRename?: () => void;
@@ -1337,12 +1488,7 @@ function ProjectGroupHeader({
updatedAt?: string | null; updatedAt?: string | null;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const projectButton = (
return (
<div
title={path}
className="group flex min-w-0 items-center gap-1 px-1 pb-1 pt-1 text-[12px] font-medium text-muted-foreground/78"
>
<button <button
type="button" type="button"
aria-expanded={!collapsed} aria-expanded={!collapsed}
@@ -1352,13 +1498,35 @@ function ProjectGroupHeader({
<Folder className="h-3.5 w-3.5 shrink-0" aria-hidden /> <Folder className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">{label}</span> <span className="min-w-0 flex-1 truncate">{label}</span>
</button> </button>
);
const disclosureLabel = `${t("chat.groups.projects")}: ${label}`;
return (
<div
onContextMenu={onRequestRename || onNewChat
? (event) => actionMenus.openFromContextMenu(event, actionMenuId)
: undefined}
className="group flex min-w-0 items-center gap-1 px-1 pb-1 pt-1 text-[12px] font-medium text-muted-foreground/78"
>
{path ? (
<Tooltip>
<TooltipTrigger asChild>{projectButton}</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-72 break-words">
{path}
</TooltipContent>
</Tooltip>
) : projectButton}
{updatedAt ? ( {updatedAt ? (
<span className="shrink-0 text-[11px] text-muted-foreground/55"> <span className="shrink-0 text-[11px] text-muted-foreground/55">
{relativeTime(updatedAt)} {relativeTime(updatedAt)}
</span> </span>
) : null} ) : null}
{onRequestRename ? ( {onRequestRename || onNewChat ? (
<DropdownMenu modal={false}> <DropdownMenu
modal={false}
open={actionMenus.openId === actionMenuId}
onOpenChange={(open) => actionMenus.onOpenChange(actionMenuId, open)}
>
<DropdownMenuTrigger <DropdownMenuTrigger
className={cn( className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity", "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity",
@@ -1376,30 +1544,45 @@ function ProjectGroupHeader({
portalContainer={actionMenuPortalContainer} portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()}
> >
{onNewChat ? (
<DropdownMenuItem onSelect={onNewChat}>
<Plus className="h-4 w-4 shrink-0" aria-hidden />
{t("sidebar.newChat")}
</DropdownMenuItem>
) : null}
{onRequestRename ? (
<DropdownMenuItem onSelect={onRequestRename}> <DropdownMenuItem onSelect={onRequestRename}>
<Pencil className="h-4 w-4 shrink-0" /> <Pencil className="h-4 w-4 shrink-0" />
{t("chat.rename")} {t("chat.rename")}
</DropdownMenuItem> </DropdownMenuItem>
) : null}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null} ) : null}
{onNewChat ? ( <SidebarItemTooltip label={disclosureLabel}>
<button <button
type="button" type="button"
aria-label={t("chat.newInProject", { project: label })} aria-expanded={!collapsed}
title={t("chat.newInProject", { project: label })} aria-label={disclosureLabel}
onClick={(event) => { onClick={onToggle}
event.stopPropagation();
onNewChat();
}}
className={cn( className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity", "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100 focus-visible:opacity-100", "text-muted-foreground/70 transition-[background-color,color,transform] duration-150 ease-out",
"hover:bg-sidebar-accent hover:text-sidebar-foreground active:scale-[0.96]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
"motion-reduce:transition-none motion-reduce:active:scale-100",
)} )}
> >
<Plus className="h-3.5 w-3.5" /> <ChevronDown
data-sidebar-project-disclosure-icon
aria-hidden
className={cn(
"h-3.5 w-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none",
collapsed && "rotate-90",
)}
/>
</button> </button>
) : null} </SidebarItemTooltip>
</div> </div>
); );
} }
@@ -1412,11 +1595,11 @@ function ChatsGroupHeader({ label }: { label: string }) {
); );
} }
function PinnedChatIndicator({ label }: { label: string }) { function PinnedChatIndicator() {
return ( return (
<span <span
data-sidebar-pinned-indicator
aria-hidden="true" aria-hidden="true"
title={label}
className="inline-flex shrink-0 items-center text-muted-foreground/65" className="inline-flex shrink-0 items-center text-muted-foreground/65"
> >
<Pin className="h-3.5 w-3.5" aria-hidden="true" /> <Pin className="h-3.5 w-3.5" aria-hidden="true" />
@@ -1468,26 +1651,30 @@ function SessionActivityIndicator({
if (state === "running") { if (state === "running") {
const label = t("chat.activity.running"); const label = t("chat.activity.running");
return ( return (
<SidebarItemTooltip label={label}>
<span <span
role="img"
aria-label={label} aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center" className="grid h-4 w-4 shrink-0 place-items-center"
> >
<span className="h-3 w-3 animate-spin rounded-full border border-blue-500/25 border-t-blue-500 [animation-duration:1.4s] motion-reduce:animate-none dark:border-blue-400/25 dark:border-t-blue-400" /> <span className="h-3 w-3 animate-spin rounded-full border border-blue-500/25 border-t-blue-500 [animation-duration:1.4s] motion-reduce:animate-none dark:border-blue-400/25 dark:border-t-blue-400" />
</span> </span>
</SidebarItemTooltip>
); );
} }
if (state === "updated") { if (state === "updated") {
const label = t("chat.activity.updated"); const label = t("chat.activity.updated");
return ( return (
<SidebarItemTooltip label={label}>
<span <span
role="img"
aria-label={label} aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center" className="grid h-4 w-4 shrink-0 place-items-center"
> >
<span className="h-2 w-2 rounded-full bg-[#ff8a3d] shadow-[0_0_0_2px_rgba(255,138,61,0.16)]" /> <span className="h-2 w-2 rounded-full bg-[#ff8a3d] shadow-[0_0_0_2px_rgba(255,138,61,0.16)]" />
</span> </span>
</SidebarItemTooltip>
); );
} }
@@ -319,7 +319,7 @@ export function AgentActivityCluster({
syncActivityScrollFade(); syncActivityScrollFade();
}, [syncActivityScrollFade]); }, [syncActivityScrollFade]);
if (!hasVisibleActivity) return null; if (!hasVisibleActivity && !isTurnStreaming) return null;
if (hasOnlyFileActivity) { if (hasOnlyFileActivity) {
return ( return (
@@ -343,6 +343,7 @@ export function AgentActivityCluster({
contentRef={activityContentRef} contentRef={activityContentRef}
fadeTop={activityScrollFade.top} fadeTop={activityScrollFade.top}
fadeBottom={activityScrollFade.bottom} fadeBottom={activityScrollFade.bottom}
hasDetails={hasVisibleActivity}
onToggle={toggleOuter} onToggle={toggleOuter}
onScroll={onActivityScroll} onScroll={onActivityScroll}
> >
@@ -382,7 +383,13 @@ function activityDurationMs(
const timestamps = messages const timestamps = messages
.map((message) => message.createdAt) .map((message) => message.createdAt)
.filter((value) => Number.isFinite(value)); .filter((value) => Number.isFinite(value));
if (!timestamps.length) return 0; if (!timestamps.length) {
return active
&& typeof activeStartedAtMs === "number"
&& Number.isFinite(activeStartedAtMs)
? Math.max(0, now - activeStartedAtMs)
: 0;
}
const first = active && Number.isFinite(activeStartedAtMs) const first = active && Number.isFinite(activeStartedAtMs)
? activeStartedAtMs! ? activeStartedAtMs!
: Math.min(...timestamps); : Math.min(...timestamps);
+15 -55
View File
@@ -83,7 +83,6 @@ import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { useMediaQuery } from "@/hooks/useMediaQuery"; import { useMediaQuery } from "@/hooks/useMediaQuery";
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream"; import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder"; import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type { import type {
CliAppInfo, CliAppInfo,
@@ -207,8 +206,6 @@ interface ThreadComposerProps {
onStop?: () => void; onStop?: () => void;
surfaceRef?: Ref<HTMLDivElement>; surfaceRef?: Ref<HTMLDivElement>;
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>; onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
/** Unix seconds from server; turn elapsed timer above input while set. */
runStartedAt?: number | null;
/** Sustained objective for this chat (WebSocket ``goal_state``). */ /** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload; goalState?: GoalStateWsPayload;
workspaceScope?: WorkspaceScopePayload | null; workspaceScope?: WorkspaceScopePayload | null;
@@ -695,63 +692,38 @@ function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMentio
}; };
} }
function RunPulseIcon() { function GoalStateStrip({
return (
<span className="run-pulse-icon relative flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden>
<span className="run-pulse-icon__ring" />
<span className="run-pulse-icon__dot" />
</span>
);
}
function RunElapsedStrip({
startedAt,
goalState, goalState,
}: { }: {
startedAt: number | null;
goalState?: GoalStateWsPayload; goalState?: GoalStateWsPayload;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const pageVisible = usePageVisibility();
const [goalPanelOpen, setGoalPanelOpen] = useState(false); const [goalPanelOpen, setGoalPanelOpen] = useState(false);
const showTimer = startedAt != null;
const stripLabel = goalStateStripPreview(goalState, t); const stripLabel = goalStateStripPreview(goalState, t);
const showGoal = !!stripLabel?.trim(); const active = !!stripLabel?.trim();
const active = showTimer || showGoal;
const [, setTick] = useState(0); const [, setTick] = useState(0);
const stripWrapperRef = useRef<HTMLDivElement>(null); const stripWrapperRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const expandToggleRef = useRef<HTMLButtonElement>(null); const expandToggleRef = useRef<HTMLButtonElement>(null);
const stripSnapshotRef = useRef<{ const stripSnapshotRef = useRef<{
startedAt: number | null;
goalState?: GoalStateWsPayload; goalState?: GoalStateWsPayload;
stripLabel: string | null; stripLabel: string | null;
} | null>(null); } | null>(null);
const [panelMaxPx, setPanelMaxPx] = useState(280); const [panelMaxPx, setPanelMaxPx] = useState(280);
if (active) { if (active) {
stripSnapshotRef.current = { startedAt, goalState, stripLabel }; stripSnapshotRef.current = { goalState, stripLabel };
} }
useEffect(() => { useEffect(() => {
if (!active) setGoalPanelOpen(false); if (!active) setGoalPanelOpen(false);
}, [active]); }, [active]);
useEffect(() => {
if (startedAt == null || !pageVisible) return;
setTick((n) => n + 1);
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, [pageVisible, startedAt]);
const display = active const display = active
? { startedAt, goalState, stripLabel } ? { goalState, stripLabel }
: stripSnapshotRef.current; : stripSnapshotRef.current;
const displayStartedAt = display?.startedAt ?? null;
const displayGoalState = display?.goalState; const displayGoalState = display?.goalState;
const displayStripLabel = display?.stripLabel ?? null; const displayStripLabel = display?.stripLabel ?? null;
const displayShowTimer = displayStartedAt != null;
const displayShowGoal = !!displayStripLabel?.trim();
const objectiveFull = displayGoalState?.objective?.trim() ?? ""; const objectiveFull = displayGoalState?.objective?.trim() ?? "";
const summaryFull = displayGoalState?.ui_summary?.trim() ?? ""; const summaryFull = displayGoalState?.ui_summary?.trim() ?? "";
@@ -819,17 +791,11 @@ function RunElapsedStrip({
}; };
}, [goalPanelOpen]); }, [goalPanelOpen]);
const elapsed = if (!display) return null;
displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0;
const m = Math.floor(elapsed / 60);
const sec = elapsed % 60;
const shortElapsed = m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`;
const timerTitle = displayShowTimer
? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed })
: null;
const ariaParts = [timerTitle, displayShowGoal ? displayStripLabel : null].filter(Boolean); const ariaLabel = displayStripLabel
const ariaLabel = ariaParts.join(" · "); ? t("thread.composer.goalStateStrip", { label: displayStripLabel })
: t("thread.composer.goalStateFallback");
return ( return (
<div <div
@@ -838,6 +804,11 @@ function RunElapsedStrip({
data-composer-status-drawer="" data-composer-status-drawer=""
data-state={active ? "open" : "closed"} data-state={active ? "open" : "closed"}
aria-hidden={active ? undefined : true} aria-hidden={active ? undefined : true}
onTransitionEnd={(event) => {
if (active || event.target !== event.currentTarget) return;
stripSnapshotRef.current = null;
setTick((n) => n + 1);
}}
> >
{goalPanelOpen && canExpandGoal && markdownBody ? ( {goalPanelOpen && canExpandGoal && markdownBody ? (
<div <div
@@ -891,19 +862,9 @@ function RunElapsedStrip({
role="status" role="status"
aria-label={ariaLabel} aria-label={ariaLabel}
> >
{displayShowTimer ? (
<RunPulseIcon />
) : (
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden /> <Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
)}
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75"> <span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null} {displayStripLabel ? (
{timerTitle && displayShowGoal ? (
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
·
</span>
) : null}
{displayShowGoal ? (
<span className="truncate"> <span className="truncate">
{t("thread.composer.goalStateStrip", { label: displayStripLabel })} {t("thread.composer.goalStateStrip", { label: displayStripLabel })}
</span> </span>
@@ -963,7 +924,6 @@ export function ThreadComposer({
onStop, onStop,
surfaceRef, surfaceRef,
onTranscribeAudio, onTranscribeAudio,
runStartedAt = null,
goalState, goalState,
workspaceScope = null, workspaceScope = null,
workspaceControlsHidden = false, workspaceControlsHidden = false,
@@ -2370,7 +2330,7 @@ export function ThreadComposer({
</button> </button>
</div> </div>
) : null} ) : null}
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} /> <GoalStateStrip goalState={goalState} />
<div className="relative"> <div className="relative">
{hasMentionDecorations ? ( {hasMentionDecorations ? (
<ComposerCliMentionOverlay <ComposerCliMentionOverlay
@@ -11,6 +11,9 @@ interface ThreadMessagesProps {
temporary?: boolean; temporary?: boolean;
/** When true, agent turn still in flight — keeps activity timeline expanded. */ /** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean; isStreaming?: boolean;
activeTurnId?: string | null;
/** Optimistic or canonical active-turn start, in unix seconds. */
runStartedAt?: number | null;
hiddenUserMessageCount?: number; hiddenUserMessageCount?: number;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
@@ -53,6 +56,8 @@ export function ThreadMessages({
messages, messages,
temporary = false, temporary = false,
isStreaming = false, isStreaming = false,
activeTurnId = null,
runStartedAt = null,
hiddenUserMessageCount = 0, hiddenUserMessageCount = 0,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
@@ -74,6 +79,16 @@ export function ThreadMessages({
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(), () => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units], [isStreaming, units],
); );
const pendingTurn = useMemo(
() => pendingTurnProjection(messages, activeTurnId),
[activeTurnId, messages],
);
const pendingActivity = (
isStreaming
&& liveActivityClusterIndices.size === 0
&& pendingTurn !== null
&& !pendingTurn.hasVisibleOutput
) ? pendingTurn : null;
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]); const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
let nextUserIndex = hiddenUserMessageCount; let nextUserIndex = hiddenUserMessageCount;
@@ -136,10 +151,68 @@ export function ThreadMessages({
/> />
); );
})} })}
{pendingActivity ? (
<div className={units.length > 0 ? "mt-5" : undefined}>
<AgentActivityCluster
messages={[]}
isTurnStreaming
hasBodyBelow={false}
startedAtMs={
runStartedAt != null
? runStartedAt * 1000
: pendingActivity.startedAtMs
}
/>
</div>
) : null}
</div> </div>
); );
} }
interface PendingTurnProjection {
startedAtMs?: number;
hasVisibleOutput: boolean;
}
function pendingTurnProjection(
messages: UIMessage[],
activeTurnId: string | null,
): PendingTurnProjection | null {
let promptIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (
message.role === "user"
&& message.deliveryStatus !== "failed"
&& (activeTurnId === null || message.turnId === activeTurnId)
) {
promptIndex = index;
break;
}
}
if (promptIndex < 0) return null;
const prompt = messages[promptIndex];
const hasVisibleOutput = messages.slice(promptIndex + 1).some((message) => {
if (message.role === "user") return false;
if (activeTurnId && message.turnId && message.turnId !== activeTurnId) return false;
return (
message.content.trim().length > 0
|| !!message.reasoning?.trim()
|| !!message.reasoningStreaming
|| message.kind === "trace"
|| !!message.media?.length
);
});
return {
...(typeof prompt.createdAt === "number" && Number.isFinite(prompt.createdAt)
? { startedAtMs: prompt.createdAt }
: {}),
hasVisibleOutput,
};
}
interface ThreadDisplayUnitProps { interface ThreadDisplayUnitProps {
unit: DisplayUnit; unit: DisplayUnit;
marginTop: string; marginTop: string;
+1 -2
View File
@@ -1458,7 +1458,6 @@ export function ThreadShell({
skills={skills} skills={skills}
onStop={stop} onStop={stop}
onTranscribeAudio={transcribeAudio} onTranscribeAudio={transcribeAudio}
runStartedAt={currentRunStartedAt}
goalState={currentGoalState} goalState={currentGoalState}
workspaceScope={workspaceScope} workspaceScope={workspaceScope}
workspaceControlsHidden={temporary} workspaceControlsHidden={temporary}
@@ -1505,7 +1504,6 @@ export function ThreadShell({
sessions={mentionSessions} sessions={mentionSessions}
skills={skills} skills={skills}
surfaceRef={composerSurfaceRef} surfaceRef={composerSurfaceRef}
runStartedAt={currentRunStartedAt}
onTranscribeAudio={transcribeAudio} onTranscribeAudio={transcribeAudio}
goalState={currentGoalState} goalState={currentGoalState}
workspaceScope={workspaceScope} workspaceScope={workspaceScope}
@@ -1579,6 +1577,7 @@ export function ThreadShell({
messages={displayMessages} messages={displayMessages}
temporary={temporary} temporary={temporary}
isStreaming={turnActive} isStreaming={turnActive}
runStartedAt={currentRunStartedAt}
emptyState={emptyState} emptyState={emptyState}
composer={composerPortalTarget === undefined ? composer : null} composer={composerPortalTarget === undefined ? composer : null}
activeTurnId={viewportTurnId} activeTurnId={viewportTurnId}
+74 -4
View File
@@ -37,6 +37,8 @@ interface ThreadViewportProps {
messages: UIMessage[]; messages: UIMessage[];
temporary?: boolean; temporary?: boolean;
isStreaming: boolean; isStreaming: boolean;
/** Optimistic or canonical start time for the active turn, in unix seconds. */
runStartedAt?: number | null;
composer?: ReactNode; composer?: ReactNode;
emptyState?: ReactNode; emptyState?: ReactNode;
scrollToBottomSignal?: number; scrollToBottomSignal?: number;
@@ -64,6 +66,9 @@ const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16; const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16; const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80; const SOFT_KEYBOARD_MIN_INSET_PX = 80;
const SESSION_HANDOFF_EXIT_DURATION_MS = 80;
const SESSION_HANDOFF_ENTER_DURATION_MS = 140;
const SESSION_HANDOFF_OPACITY = 0.82;
export const INITIAL_HISTORY_WINDOW = 160; export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120; export const HISTORY_WINDOW_INCREMENT = 120;
@@ -104,6 +109,13 @@ function isThreadDisclosureTarget(target: EventTarget | null): boolean {
&& target.closest("[data-thread-disclosure]") !== null; && target.closest("[data-thread-disclosure]") !== null;
} }
function isKeyboardControl(element: Element | null): boolean {
return element instanceof HTMLElement
&& element.closest(
"button, a[href], select, [role='button'], [role='menuitem'], [role='option']",
) !== null;
}
type ThreadScrollDirection = "backward" | "forward"; type ThreadScrollDirection = "backward" | "forward";
const KEYBOARD_SCROLL_DIRECTIONS: Readonly< const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
@@ -161,6 +173,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
messages, messages,
temporary = false, temporary = false,
isStreaming, isStreaming,
runStartedAt = null,
composer, composer,
emptyState, emptyState,
scrollToBottomSignal = 0, scrollToBottomSignal = 0,
@@ -187,9 +200,12 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const messageRegionRef = useRef<HTMLDivElement>(null); const messageRegionRef = useRef<HTMLDivElement>(null);
const messageContentRef = useRef<HTMLDivElement>(null); const messageContentRef = useRef<HTMLDivElement>(null);
const emptyStateRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null); const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey); const lastConversationKeyRef = useRef<string | null>(conversationKey);
const conversationHandoffPendingRef = useRef(false);
const conversationHandoffAnimationRef = useRef<Animation | null>(null);
const pendingConversationScrollRef = useRef(true); const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null); const pendingPromptJumpRef = useRef<string | null>(null);
const restoreScrollAfterPrependRef = const restoreScrollAfterPrependRef =
@@ -422,11 +438,27 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
useLayoutEffect(() => { useLayoutEffect(() => {
if (lastConversationKeyRef.current === conversationKey) return; if (lastConversationKeyRef.current === conversationKey) return;
lastConversationKeyRef.current = conversationKey; lastConversationKeyRef.current = conversationKey;
conversationHandoffAnimationRef.current?.cancel();
conversationHandoffAnimationRef.current = null;
conversationHandoffPendingRef.current = true;
pendingConversationScrollRef.current = true; pendingConversationScrollRef.current = true;
threadMotionRef.current?.reset(); threadMotionRef.current?.reset();
setAtBottom(true); setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW); setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
}, [conversationKey]);
const surface = hasMessages ? messageRegionRef.current : emptyStateRef.current;
const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!surface || reduceMotion || typeof surface.animate !== "function") return;
conversationHandoffAnimationRef.current = surface.animate(
[{ opacity: 1 }, { opacity: SESSION_HANDOFF_OPACITY }],
{
duration: SESSION_HANDOFF_EXIT_DURATION_MS,
easing: "cubic-bezier(0.2, 0, 0, 1)",
fill: "forwards",
},
);
}, [conversationKey, hasMessages]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!conversationReady) { if (!conversationReady) {
@@ -513,11 +545,41 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
scrollToBottom, scrollToBottom,
]); ]);
useLayoutEffect(() => {
if (!conversationReady || !conversationHandoffPendingRef.current) return;
conversationHandoffPendingRef.current = false;
conversationHandoffAnimationRef.current?.cancel();
conversationHandoffAnimationRef.current = null;
const surface = hasMessages ? messageRegionRef.current : emptyStateRef.current;
const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!surface || reduceMotion || typeof surface.animate !== "function") return;
const animation = surface.animate(
[{ opacity: SESSION_HANDOFF_OPACITY }, { opacity: 1 }],
{
duration: SESSION_HANDOFF_ENTER_DURATION_MS,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
);
conversationHandoffAnimationRef.current = animation;
const clearAnimation = () => {
if (conversationHandoffAnimationRef.current === animation) {
conversationHandoffAnimationRef.current = null;
}
};
animation.onfinish = clearAnimation;
animation.oncancel = clearAnimation;
}, [conversationReady, hasMessages]);
useLayoutEffect(() => { useLayoutEffect(() => {
threadMotionRef.current?.invalidateGeometry(); threadMotionRef.current?.invalidateGeometry();
}, [composer, hasMessages, visibleMessages.length]); }, [composer, hasMessages, visibleMessages.length]);
useEffect(() => () => threadMotionRef.current?.dispose(), []); useEffect(() => () => {
conversationHandoffAnimationRef.current?.cancel();
threadMotionRef.current?.dispose();
}, []);
useLayoutEffect(() => { useLayoutEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
@@ -530,10 +592,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const invalidateGeometry = () => { const invalidateGeometry = () => {
threadMotionRef.current?.invalidateGeometry(); threadMotionRef.current?.invalidateGeometry();
}; };
invalidateGeometry(); const reconcileObservedGeometry = () => {
threadMotionRef.current?.reconcileObservedGeometry();
};
reconcileObservedGeometry();
const observer = typeof ResizeObserver === "undefined" const observer = typeof ResizeObserver === "undefined"
? null ? null
: new ResizeObserver(invalidateGeometry); : new ResizeObserver(reconcileObservedGeometry);
observer?.observe(el); observer?.observe(el);
if (content) observer?.observe(content); if (content) observer?.observe(content);
if (messageRegion) observer?.observe(messageRegion); if (messageRegion) observer?.observe(messageRegion);
@@ -623,6 +688,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
yieldCameraToUser(); yieldCameraToUser();
return; return;
} }
if (isKeyboardControl(event.target as Element | null)) return;
handleDirectionalInput(keyboardScrollDirection(event)); handleDirectionalInput(keyboardScrollDirection(event));
}; };
el.addEventListener("scroll", handleScroll, { passive: true }); el.addEventListener("scroll", handleScroll, { passive: true });
@@ -690,6 +756,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
messages={visibleMessages} messages={visibleMessages}
temporary={temporary} temporary={temporary}
isStreaming={isStreaming} isStreaming={isStreaming}
activeTurnId={activeTurnId}
runStartedAt={runStartedAt}
hiddenUserMessageCount={hiddenUserMessageCount} hiddenUserMessageCount={hiddenUserMessageCount}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
@@ -704,6 +772,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
</div> </div>
) : ( ) : (
<div <div
ref={emptyStateRef}
data-testid="thread-empty-region"
className={cn( className={cn(
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center", "row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
hasComposer && "sm:items-end sm:pb-8", hasComposer && "sm:items-end sm:pb-8",
@@ -12,6 +12,7 @@ interface ThinkingReasoningShellProps {
contentRef: Ref<HTMLDivElement>; contentRef: Ref<HTMLDivElement>;
fadeTop: boolean; fadeTop: boolean;
fadeBottom: boolean; fadeBottom: boolean;
hasDetails?: boolean;
onToggle: () => void; onToggle: () => void;
onScroll: () => void; onScroll: () => void;
} }
@@ -25,6 +26,7 @@ export function ThinkingReasoningShell({
contentRef, contentRef,
fadeTop, fadeTop,
fadeBottom, fadeBottom,
hasDetails = true,
onToggle, onToggle,
onScroll, onScroll,
}: ThinkingReasoningShellProps) { }: ThinkingReasoningShellProps) {
@@ -33,6 +35,7 @@ export function ThinkingReasoningShell({
className="flex w-full max-w-[45rem] animate-in flex-col fade-in duration-300 motion-reduce:animate-none" className="flex w-full max-w-[45rem] animate-in flex-col fade-in duration-300 motion-reduce:animate-none"
data-state={active ? "thinking" : "done"} data-state={active ? "thinking" : "done"}
> >
{hasDetails ? (
<button <button
type="button" type="button"
data-thread-disclosure="" data-thread-disclosure=""
@@ -67,7 +70,25 @@ export function ThinkingReasoningShell({
/> />
</span> </span>
</button> </button>
) : (
<div
className="inline-flex min-h-5 items-center self-start"
role="status"
aria-label={label}
aria-live={active ? "polite" : undefined}
>
<span
className={cn(
"min-w-0 truncate text-[13px] font-medium leading-[18px] text-muted-foreground/70",
active && "animate-pulse motion-reduce:animate-none",
)}
>
{label}
</span>
</div>
)}
{hasDetails ? (
<div <div
{...(!expanded ? { inert: "" } : {})} {...(!expanded ? { inert: "" } : {})}
aria-hidden={!expanded} aria-hidden={!expanded}
@@ -107,6 +128,7 @@ export function ThinkingReasoningShell({
) : null} ) : null}
</div> </div>
</div> </div>
) : null}
</div> </div>
); );
} }
+13 -4
View File
@@ -147,10 +147,10 @@ function defaultScheduler(): ThreadMotionScheduler {
} }
/** /**
* Owns the policy that turns discrete layout events into automatic tail * Owns the policy that turns layout events into automatic tail pinning or
* pinning or explicit camera navigation. Callers only invalidate geometry; * explicit camera navigation. Discrete notifications are coalesced into one
* one display frame coalesces those notifications and reads the authoritative * display frame. ResizeObserver deliveries reconcile immediately because they
* layout before applying either policy. * already carry the browser's authoritative layout and run before paint.
*/ */
export class ThreadMotionCoordinator { export class ThreadMotionCoordinator {
private readonly camera: ThreadMotionCamera; private readonly camera: ThreadMotionCamera;
@@ -240,6 +240,15 @@ export class ThreadMotionCoordinator {
this.measurementFrameId = this.scheduler.request(this.flushGeometry); this.measurementFrameId = this.scheduler.request(this.flushGeometry);
} }
reconcileObservedGeometry(): void {
if (this.measurementFrameId !== null) {
this.scheduler.cancel(this.measurementFrameId);
this.measurementFrameId = null;
}
this.geometryDirty = true;
this.flushGeometry();
}
handleComposerInput(): void { handleComposerInput(): void {
// Input and protocol completion can arrive in either order. Remember // Input and protocol completion can arrive in either order. Remember
// editing that starts just before turn_end so the completion drawer // editing that starts just before turn_end so the completion drawer
@@ -220,7 +220,9 @@ export function PaneWorkbench({
const gridRef = useRef<HTMLDivElement | null>(null); const gridRef = useRef<HTMLDivElement | null>(null);
const paneRefs = useRef(new Map<string, HTMLElement>()); const paneRefs = useRef(new Map<string, HTMLElement>());
const lastRectsRef = useRef(new Map<string, DOMRect>()); const lastRectsRef = useRef(new Map<string, DOMRect>());
const lastElementRectsRef = useRef(new Map<HTMLElement, DOMRect>());
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null); const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
const pendingElementRectsRef = useRef<Map<HTMLElement, DOMRect> | null>(null);
const animationsRef = useRef(new Map<string, Animation>()); const animationsRef = useRef(new Map<string, Animation>());
const sourceSplitRatiosKey = splitRatios.join("\u0000"); const sourceSplitRatiosKey = splitRatios.join("\u0000");
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios); const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
@@ -284,24 +286,36 @@ export function PaneWorkbench({
return rects; return rects;
}, []); }, []);
const measurePaneElements = useCallback(() => {
const rects = new Map<HTMLElement, DOMRect>();
for (const element of paneRefs.current.values()) {
if (!element.hidden) rects.set(element, element.getBoundingClientRect());
}
return rects;
}, []);
const captureLayout = useCallback(() => { const captureLayout = useCallback(() => {
pendingRectsRef.current = measurePanes(); pendingRectsRef.current = measurePanes();
pendingElementRectsRef.current = measurePaneElements();
for (const animation of animationsRef.current.values()) animation.cancel(); for (const animation of animationsRef.current.values()) animation.cancel();
animationsRef.current.clear(); animationsRef.current.clear();
}, [measurePanes]); }, [measurePaneElements, measurePanes]);
useLayoutEffect(() => { useLayoutEffect(() => {
const previousRects = pendingRectsRef.current ?? lastRectsRef.current; const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
const previousElementRects = pendingElementRectsRef.current ?? lastElementRectsRef.current;
pendingRectsRef.current = null; pendingRectsRef.current = null;
pendingElementRectsRef.current = null;
const nextRects = measurePanes(); const nextRects = measurePanes();
const nextElementRects = measurePaneElements();
const reduceMotion = typeof window.matchMedia === "function" const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches; && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) { if (!reduceMotion) {
for (const [key, nextRect] of nextRects) { for (const [key, nextRect] of nextRects) {
const previousRect = previousRects.get(key);
const element = paneRefs.current.get(key); const element = paneRefs.current.get(key);
if (!element) continue; if (!element) continue;
const previousRect = previousRects.get(key) ?? previousElementRects.get(element);
if (!previousRect) { if (!previousRect) {
if (previousRects.size === 0 || typeof element.animate !== "function") continue; if (previousRects.size === 0 || typeof element.animate !== "function") continue;
const animation = element.animate( const animation = element.animate(
@@ -356,7 +370,8 @@ export function PaneWorkbench({
} }
} }
lastRectsRef.current = nextRects; lastRectsRef.current = nextRects;
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]); lastElementRectsRef.current = nextElementRects;
}, [activePaneKey, effectiveLayout, measurePaneElements, measurePanes, paneOrder]);
useEffect(() => () => { useEffect(() => () => {
for (const animation of animationsRef.current.values()) animation.cancel(); for (const animation of animationsRef.current.values()) animation.cancel();
-56
View File
@@ -478,50 +478,6 @@
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes run-pulse-dot {
0%,
100% {
transform: scale(0.9);
opacity: 0.76;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
@keyframes run-pulse-ring {
0% {
transform: scale(0.42);
opacity: 0.34;
}
100% {
transform: scale(1.28);
opacity: 0;
}
}
.run-pulse-icon {
color: hsl(204 82% 46%);
}
.run-pulse-icon__ring,
.run-pulse-icon__dot {
display: block;
border-radius: 999px;
pointer-events: none;
}
.run-pulse-icon__ring {
position: absolute;
height: 12px;
width: 12px;
background: hsl(204 82% 46% / 0.22);
animation: run-pulse-ring 1.55s ease-out infinite;
}
.run-pulse-icon__dot {
height: 6px;
width: 6px;
background: currentColor;
box-shadow: 0 0 0 1px hsl(204 82% 46% / 0.14);
animation: run-pulse-dot 1.55s ease-in-out infinite;
}
@keyframes queued-prompt-row-enter { @keyframes queued-prompt-row-enter {
0% { 0% {
opacity: 0; opacity: 0;
@@ -552,18 +508,6 @@
.thread-layout { .thread-layout {
transition-duration: 0.01ms; transition-duration: 0.01ms;
} }
.run-pulse-icon,
.run-pulse-icon * {
animation: none;
}
.run-pulse-icon__ring {
opacity: 0.18;
transform: scale(1);
}
.run-pulse-icon__dot {
opacity: 1;
transform: scale(1);
}
.queued-prompt-row { .queued-prompt-row {
animation: none; animation: none;
} }
+22 -19
View File
@@ -2040,18 +2040,20 @@ describe("App layout", () => {
act(() => { act(() => {
for (const handler of runStatusHandlers) handler("chat-a", 12_345); for (const handler of runStatusHandlers) handler("chat-a", 12_345);
}); });
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(); expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument();
act(() => { act(() => {
for (const handler of runStatusHandlers) handler("chat-a", null); for (const handler of runStatusHandlers) handler("chat-a", null);
}); });
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument(); expect(within(sidebar).queryByRole("img", { name: "Agent running" }))
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument(); .not.toBeInTheDocument();
expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
await act(async () => { await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ })); fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
}); });
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument(); expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
}); });
it("does not show an updated dot later when the active session finishes", async () => { it("does not show an updated dot later when the active session finishes", async () => {
@@ -2092,18 +2094,21 @@ describe("App layout", () => {
act(() => { act(() => {
for (const handler of runStatusHandlers) handler("chat-a", 12_345); for (const handler of runStatusHandlers) handler("chat-a", 12_345);
}); });
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(); expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument();
act(() => { act(() => {
for (const handler of runStatusHandlers) handler("chat-a", null); for (const handler of runStatusHandlers) handler("chat-a", null);
}); });
expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument(); expect(within(sidebar).queryByRole("img", { name: "Agent running" }))
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument(); .not.toBeInTheDocument();
expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
await act(async () => { await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ })); fireEvent.click(within(sidebar).getByRole("button", { name: /^Other chat$/ }));
}); });
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument(); expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
}); });
it("marks inactive sessions when a thread update arrives", async () => { it("marks inactive sessions when a thread update arrives", async () => {
@@ -2138,13 +2143,14 @@ describe("App layout", () => {
for (const handler of sessionUpdateHandlers) handler("chat-b", "thread"); for (const handler of sessionUpdateHandlers) handler("chat-b", "thread");
}); });
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument(); expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
await act(async () => { await act(async () => {
fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ })); fireEvent.click(within(sidebar).getByRole("button", { name: /^Scheduled update target$/ }));
}); });
expect(within(sidebar).queryByTitle("New activity")).not.toBeInTheDocument(); expect(within(sidebar).queryByRole("img", { name: "New activity" }))
.not.toBeInTheDocument();
}); });
it("restores sidebar run indicators after a page reload", async () => { it("restores sidebar run indicators after a page reload", async () => {
@@ -2177,9 +2183,9 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() => await waitFor(() =>
expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(), expect(within(sidebar).getByRole("img", { name: "Agent running" })).toBeInTheDocument(),
); );
expect(within(sidebar).getByTitle("New activity")).toBeInTheDocument(); expect(within(sidebar).getByRole("img", { name: "New activity" })).toBeInTheDocument();
expect(attachSpy).toHaveBeenCalledWith("chat-a"); expect(attachSpy).toHaveBeenCalledWith("chat-a");
}); });
@@ -3099,13 +3105,10 @@ describe("App layout", () => {
.toBeTruthy(); .toBeTruthy();
const alphaGroup = alphaTab.closest("[data-sidebar-tab-group]") as HTMLElement; const alphaGroup = alphaTab.closest("[data-sidebar-tab-group]") as HTMLElement;
const paneTitles = within(alphaGroup) const alphaChild = within(alphaGroup).getByRole("button", { name: "Alpha child" });
.getAllByRole("button") const alphaRoot = within(alphaGroup).getByRole("button", { name: "Alpha tab" });
.filter((button) => ( expect(alphaChild.compareDocumentPosition(alphaRoot) & Node.DOCUMENT_POSITION_FOLLOWING)
button.closest("[data-sidebar-pane]") && button.hasAttribute("title") .toBeTruthy();
))
.map((button) => button.getAttribute("title"));
expect(paneTitles).toEqual(["Alpha child", "Alpha tab"]);
}); });
it("uses one active pane without workbench editing controls on mobile", async () => { it("uses one active pane without workbench editing controls on mobile", async () => {
+215 -27
View File
@@ -18,13 +18,54 @@ function session(overrides: Partial<ChatSummary>): ChatSummary {
}; };
} }
function rect(top: number): DOMRect {
return {
x: 0,
y: top,
width: 240,
height: 32,
top,
right: 240,
bottom: top + 32,
left: 0,
toJSON: () => ({}),
};
}
describe("ChatList", () => { describe("ChatList", () => {
const originalAnimate = HTMLElement.prototype.animate;
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
afterEach(() => { afterEach(() => {
HTMLElement.prototype.animate = originalAnimate;
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1"); localStorage.removeItem("nanobot-webui.collapsed-pane-groups.v1");
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("opens a conversation's existing actions from the row context menu", async () => {
const onTogglePin = vi.fn();
render(
<ChatList
sessions={[session({ chatId: "review", title: "Review the patch" })]}
activeKey="websocket:review"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={onTogglePin}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const row = screen.getByRole("button", { name: "Review the patch" })
.closest("[data-chat-row]")!;
fireEvent.contextMenu(row);
fireEvent.click(await screen.findByRole("menuitem", { name: "Pin" }));
expect(onTogglePin).toHaveBeenCalledWith("websocket:review");
});
it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => { it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
render( render(
<ChatList <ChatList
@@ -71,6 +112,46 @@ describe("ChatList", () => {
expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument(); expect(document.querySelector("[data-pane-snap-slot]")).not.toBeInTheDocument();
}); });
it("opens grouped pane and tab actions from their context menus", async () => {
const onRequestRename = vi.fn();
const onDissolveTab = vi.fn();
render(
<ChatList
sessions={[session({ chatId: "root", title: "Root topic" })]}
activeKey="websocket:root"
paneGroups={{
"websocket:root": {
tabKey: "websocket:root",
title: "Root topic",
activePaneKey: "websocket:root",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
],
},
}}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={onRequestRename}
onToggleArchive={vi.fn()}
onDissolveTab={onDissolveTab}
/>,
);
const paneRow = screen.getByRole("button", { name: "Research pane" })
.closest("[data-sidebar-pane]")!;
fireEvent.contextMenu(paneRow);
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
expect(onRequestRename).toHaveBeenCalledWith("websocket:child", "Research pane");
const tabRow = screen.getByRole("button", { name: "Tab: Root topic" })
.closest("[data-workbench-tab]")!;
fireEvent.contextMenu(tabRow);
fireEvent.click(await screen.findByRole("menuitem", { name: "Dissolve group" }));
expect(onDissolveTab).toHaveBeenCalledWith("websocket:root");
});
it("creates a visible tab in place and only moves panes into visible tabs", async () => { it("creates a visible tab in place and only moves panes into visible tabs", async () => {
const onAttachPane = vi.fn(); const onAttachPane = vi.fn();
const onCreateTab = vi.fn(); const onCreateTab = vi.fn();
@@ -324,21 +405,25 @@ describe("ChatList", () => {
expect(tabHeader).not.toHaveAttribute("data-chat-row"); expect(tabHeader).not.toHaveAttribute("data-chat-row");
expect(tabHeader).not.toHaveAttribute("data-sidebar-pane"); expect(tabHeader).not.toHaveAttribute("data-sidebar-pane");
expect(tabButton).not.toHaveAttribute("aria-current"); expect(tabButton).not.toHaveAttribute("aria-current");
expect(tabButton.querySelector("svg")).not.toBeInTheDocument(); expect(tabButton.querySelector(".lucide-folder-tree")).toBeInTheDocument();
const paneList = within(tabGroup).getByRole("list", { name: "Panes in Root topic" }); const paneList = within(tabGroup).getByRole("list", { name: "Panes in Root topic" });
expect(tabSurface).toContainElement(paneList); expect(tabSurface).toContainElement(paneList);
const activePane = within(tabGroup).getByRole("button", { name: "Research pane" }); const activePane = within(tabGroup).getByRole("button", { name: "Research pane" });
expect(activePane).toHaveAttribute("aria-current", "true"); expect(activePane).toHaveAttribute("aria-current", "true");
expect(activePane.closest("[data-sidebar-pane]")).toHaveClass( expect(activePane.closest("[data-sidebar-pane]")).toHaveClass("rounded-[0.65rem]");
"bg-sidebar-selected", expect(activePane.querySelector("[data-sidebar-selection-track]"))
"rounded-[0.65rem]", .toHaveAttribute("data-active", "true");
);
expect(screen.getByRole("button", { expect(screen.getByRole("button", {
name: "Research pane pane actions", name: "Research pane pane actions",
})).toHaveClass("opacity-0"); })).toHaveClass("opacity-0");
expect(within(tabGroup).getByRole("button", { name: "Root topic" })) expect(within(tabGroup).getByRole("button", { name: "Root topic" }))
.not.toHaveAttribute("aria-current"); .not.toHaveAttribute("aria-current");
expect(tabGroup).not.toHaveTextContent("2/4"); expect(tabGroup).not.toHaveTextContent("2/4");
expect(paneList).toHaveClass(
"rounded-es-[14px]",
"border-s-2",
"border-sidebar-foreground/25",
);
const collapse = within(tabGroup).getByRole("button", { const collapse = within(tabGroup).getByRole("button", {
name: "Collapse panes in Root topic", name: "Collapse panes in Root topic",
@@ -354,7 +439,6 @@ describe("ChatList", () => {
})).toHaveAttribute("aria-expanded", "false"); })).toHaveAttribute("aria-expanded", "false");
expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" })) expect(within(tabGroup).getByRole("button", { name: "Tab: Root topic" }))
.not.toHaveAttribute("aria-current"); .not.toHaveAttribute("aria-current");
expect(tabSurface).toHaveClass("bg-sidebar-foreground/[0.045]");
fireEvent.click(within(tabGroup).getByRole("button", { fireEvent.click(within(tabGroup).getByRole("button", {
name: "Expand panes in Root topic", name: "Expand panes in Root topic",
@@ -517,10 +601,13 @@ describe("ChatList", () => {
); );
const pinnedSection = screen.getByRole("region", { name: "Pinned" }); const pinnedSection = screen.getByRole("region", { name: "Pinned" });
expect(within(pinnedSection).getByTitle("Pinned")).toBeInTheDocument();
expect( expect(
within(screen.getByRole("region", { name: "Earlier" })).queryByTitle("Pinned"), within(pinnedSection)
).not.toBeInTheDocument(); .getByText("Pinned chat")
.closest("[data-chat-row]")
?.querySelector("[data-sidebar-pinned-indicator]"),
).toBeInTheDocument();
expect(document.querySelectorAll("[data-sidebar-pinned-indicator]")).toHaveLength(1);
}); });
it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => { it("groups WebUI chats by workspace project while preserving in-project sorting and activity", () => {
@@ -574,8 +661,16 @@ describe("ChatList", () => {
const nanobotSection = screen.getByRole("region", { name: "nanobot" }); const nanobotSection = screen.getByRole("region", { name: "nanobot" });
const nanobotText = nanobotSection.textContent ?? ""; const nanobotText = nanobotSection.textContent ?? "";
const projectSurface = nanobotSection.querySelector(
"[data-sidebar-project-surface]",
);
expect(screen.getByRole("region", { name: "nanobot-bench" })).toBeInTheDocument(); expect(screen.getByRole("region", { name: "nanobot-bench" })).toBeInTheDocument();
expect(projectSurface).toHaveClass(
"rounded-es-[16px]",
"border-s-2",
"border-sidebar-foreground/10",
);
expect(within(nanobotSection).getByText("Alpha task")).toBeInTheDocument(); expect(within(nanobotSection).getByText("Alpha task")).toBeInTheDocument();
expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument(); expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument();
expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task")); expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task"));
@@ -630,7 +725,7 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
}); });
it("switches row-owned tab highlights without a moving selection surface", () => { it("grows and retracts the row-owned selection track", () => {
const props = { const props = {
sessions: [ sessions: [
session({ chatId: "active", title: "Active topic" }), session({ chatId: "active", title: "Active topic" }),
@@ -650,12 +745,10 @@ describe("ChatList", () => {
/>, />,
); );
const activeButton = screen.getByTitle("Active topic"); const activeButton = screen.getByRole("button", { name: "Active topic" });
expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.closest("[data-sidebar-tab]")).toHaveClass( expect(activeButton.querySelector("[data-sidebar-selection-track]"))
"bg-sidebar-selected", .toHaveClass("origin-left", "scale-x-100", "transition-transform", "bg-current");
);
expect(screen.queryByTestId("sessions-selection-highlight")).not.toBeInTheDocument();
rerender( rerender(
<ChatList <ChatList
@@ -664,11 +757,16 @@ describe("ChatList", () => {
/>, />,
); );
expect(screen.getByTitle("Active topic")).not.toHaveAttribute("aria-current"); expect(screen.getByRole("button", { name: "Active topic" }))
expect(screen.getByTitle("Inactive topic")).toHaveAttribute("aria-current", "page"); .not.toHaveAttribute("aria-current");
expect(screen.getByTitle("Inactive topic").closest("[data-sidebar-tab]")).toHaveClass( expect(screen.getByRole("button", { name: "Inactive topic" }))
"bg-sidebar-selected", .toHaveAttribute("aria-current", "page");
); expect(screen.getByRole("button", { name: "Active topic" })
.querySelector("[data-sidebar-selection-track]"))
.toHaveClass("scale-x-0");
expect(screen.getByRole("button", { name: "Inactive topic" })
.querySelector("[data-sidebar-selection-track]"))
.toHaveClass("scale-x-100");
}); });
it("restores collapsed tabs from the local UI preference", () => { it("restores collapsed tabs from the local UI preference", () => {
@@ -743,21 +841,111 @@ describe("ChatList", () => {
expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/nanobot"); expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/nanobot");
expect(within(projectSection).queryByText("Alpha task")).not.toBeInTheDocument(); expect(within(projectSection).queryByText("Alpha task")).not.toBeInTheDocument();
fireEvent.click( const projectButton = within(projectSection).getByRole("button", { name: "Photos" });
within(projectSection).getByRole("button", { name: "Start a new topic in Photos" }), fireEvent.contextMenu(projectButton);
); fireEvent.click(await screen.findByRole("menuitem", { name: "New topic" }));
expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos"); expect(onNewChatInProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
expect(onToggleGroup).toHaveBeenCalledTimes(1); expect(onToggleGroup).toHaveBeenCalledTimes(1);
fireEvent.pointerDown( fireEvent.contextMenu(projectButton);
within(projectSection).getByLabelText("Topic actions for Photos"),
{ button: 0 },
);
fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" }));
expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos"); expect(onRequestRenameProject).toHaveBeenCalledWith("/Users/me/nanobot", "Photos");
}); });
it("animates project disclosure and surrounding layout like tab groups", () => {
let collapsed = false;
const onToggleGroup = vi.fn();
const animate = vi.fn(() => ({
addEventListener: vi.fn(),
cancel: vi.fn(),
}) as unknown as Animation);
HTMLElement.prototype.animate = animate;
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
const followsCollapsedProject = this.textContent?.includes("Beta") ?? false;
return rect(followsCollapsedProject ? (collapsed ? 64 : 160) : 0);
};
const sessions = [
session({
chatId: "alpha",
title: "Alpha task",
workspaceScope: {
project_path: "/Users/me/alpha",
project_name: "Alpha project",
access_mode: "restricted",
},
}),
session({
chatId: "beta",
title: "Beta task",
workspaceScope: {
project_path: "/Users/me/beta",
project_name: "Beta project",
access_mode: "restricted",
},
}),
];
const props = {
sessions,
activeKey: "websocket:alpha",
onSelect: vi.fn(),
onRequestDelete: vi.fn(),
onTogglePin: vi.fn(),
onRequestRename: vi.fn(),
onRequestRenameProject: vi.fn(),
onToggleArchive: vi.fn(),
onToggleGroup,
};
const { rerender } = render(
<ChatList {...props} collapsedGroups={{ "project:/Users/me/alpha": false }} />,
);
const projectButton = screen.getByRole("button", { name: "Alpha project" });
const disclosureButton = screen.getByRole("button", {
name: "Projects: Alpha project",
});
expect(projectButton).toHaveAttribute("aria-expanded", "true");
expect(disclosureButton).toHaveAttribute("aria-expanded", "true");
const expandedIcon = disclosureButton
.querySelector("[data-sidebar-project-disclosure-icon]");
expect(expandedIcon).toHaveClass(
"transition-transform",
"duration-200",
"ease-out",
"motion-reduce:transition-none",
);
expect(expandedIcon).not.toHaveClass("rotate-90");
expect(screen.getByRole("button", { name: "Topic actions for Alpha project" })
.compareDocumentPosition(disclosureButton) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy();
fireEvent.click(disclosureButton);
expect(onToggleGroup).toHaveBeenCalledWith("project:/Users/me/alpha");
collapsed = true;
rerender(
<ChatList {...props} collapsedGroups={{ "project:/Users/me/alpha": true }} />,
);
expect(screen.getByRole("button", { name: "Projects: Alpha project" })
.querySelector("[data-sidebar-project-disclosure-icon]"))
.toHaveClass("rotate-90");
expect(projectButton).toHaveAttribute("aria-expanded", "false");
expect(animate).toHaveBeenCalledWith(
[
{ transform: "translateY(96px)" },
{ transform: "translateY(0)" },
],
{
duration: 180,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
);
expect(screen.getByRole("button", { name: "Beta project" })
.closest("[data-sidebar-group-header]"))
.toHaveAttribute("data-sidebar-group-header", "project:/Users/me/beta");
});
it("hides the updated dot for the active chat", () => { it("hides the updated dot for the active chat", () => {
const sessions = [ const sessions = [
session({ session({
+45
View File
@@ -314,6 +314,51 @@ describe("PaneWorkbench", () => {
await waitFor(() => expect(animate).toHaveBeenCalledTimes(4)); await waitFor(() => expect(animate).toHaveBeenCalledTimes(4));
}); });
it("keeps a retargeted pane surface in the same physical motion", async () => {
const common = {
layout: "columns" as const,
showLayoutControl: false,
onActivatePane: vi.fn(),
onAddPane: vi.fn(),
onLayoutChange: vi.fn(),
onPaneOrderChange: vi.fn(),
renderPane: (pane: { title: string }) => <span>{pane.title}</span>,
};
const { rerender } = render(
<PaneWorkbench
{...common}
panes={[
{ key: "alpha", reactKey: "tab-root", title: "Alpha" },
{ key: "beta", reactKey: "pane:beta", title: "Beta" },
]}
activePaneKey="alpha"
/>,
);
animate.mockClear();
rerender(
<PaneWorkbench
{...common}
panes={[
{ key: "beta", reactKey: "pane:beta", title: "Beta" },
{ key: "gamma", reactKey: "tab-root", title: "Gamma" },
]}
activePaneKey="gamma"
/>,
);
await waitFor(() => expect(animate).toHaveBeenCalledWith(
[
{ transform: "translate(-500px, 0px) scale(1, 1)" },
{ transform: "translate(0, 0) scale(1, 1)" },
],
{
duration: 260,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
));
});
it("renders only the active pane and hides workbench controls on mobile", () => { it("renders only the active pane and hides workbench controls on mobile", () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({ vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query.includes("max-width: 767px"), matches: query.includes("max-width: 767px"),
+10 -40
View File
@@ -1326,54 +1326,21 @@ describe("ThreadComposer", () => {
expect(screen.getByLabelText("Paste path")).toBeInTheDocument(); expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
}); });
it("shows turn run timer when runStartedAt is set", () => { it("closes the sustained goal through its existing drawer", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date((1_000 + 125) * 1000));
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
runStartedAt={1000}
/>,
);
const status = screen.getByRole("status");
expect(status).toHaveTextContent(/Running/);
expect(status).toHaveTextContent(/2:05/);
expect(status).toHaveClass("composer-status-drawer-content");
expect(status.closest("[data-composer-status-drawer]")).toHaveAttribute(
"data-state",
"open",
);
expect(status.querySelector(".run-pulse-icon")).not.toBeNull();
vi.useRealTimers();
});
it("opens and closes the run timer through one persistent drawer", () => {
const { container, rerender } = render( const { container, rerender } = render(
<ThreadComposer <ThreadComposer
onSend={vi.fn()} onSend={vi.fn()}
placeholder="Type your message..." placeholder="Type your message..."
runStartedAt={null} goalState={{
active: true,
objective: "Ship the release",
ui_summary: "Preparing release",
}}
/>, />,
); );
const drawer = container.querySelector("[data-composer-status-drawer]"); const drawer = container.querySelector("[data-composer-status-drawer]");
expect(drawer).not.toBeNull(); expect(drawer).not.toBeNull();
expect(drawer).toHaveAttribute("data-state", "closed");
expect(drawer).toHaveAttribute("aria-hidden", "true");
rerender(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
runStartedAt={Math.floor(Date.now() / 1000)}
/>,
);
expect(container.querySelector("[data-composer-status-drawer]")).toBe(drawer);
expect(drawer).toHaveAttribute("data-state", "open"); expect(drawer).toHaveAttribute("data-state", "open");
expect(drawer).not.toHaveAttribute("aria-hidden"); expect(drawer).not.toHaveAttribute("aria-hidden");
const status = screen.getByRole("status"); const status = screen.getByRole("status");
@@ -1383,7 +1350,7 @@ describe("ThreadComposer", () => {
<ThreadComposer <ThreadComposer
onSend={vi.fn()} onSend={vi.fn()}
placeholder="Type your message..." placeholder="Type your message..."
runStartedAt={null} goalState={{ active: false }}
/>, />,
); );
@@ -1392,6 +1359,9 @@ describe("ThreadComposer", () => {
expect(drawer).toHaveAttribute("aria-hidden", "true"); expect(drawer).toHaveAttribute("aria-hidden", "true");
expect(screen.queryByRole("status")).not.toBeInTheDocument(); expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(drawer?.querySelector('[role="status"]')).toBe(status); expect(drawer?.querySelector('[role="status"]')).toBe(status);
fireEvent.transitionEnd(drawer as Element, { propertyName: "grid-template-rows" });
expect(container.querySelector("[data-composer-status-drawer]")).toBeNull();
}); });
it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => { it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => {
+48
View File
@@ -16,6 +16,54 @@ afterEach(() => {
}); });
describe("ThreadMessages", () => { describe("ThreadMessages", () => {
it("shows optimistic turn progress in the thread before the first agent output", () => {
vi.useFakeTimers();
const now = new Date("2026-08-13T10:00:05.000Z").getTime();
vi.setSystemTime(now);
const prompt: UIMessage = {
id: "u-optimistic",
role: "user",
content: "check this",
turnId: "turn-optimistic",
turnPhase: "user",
deliveryStatus: "sending",
createdAt: now - 5_000,
};
const { rerender } = render(
<ThreadMessages
messages={[prompt]}
isStreaming
activeTurnId="turn-optimistic"
runStartedAt={(now - 5_000) / 1000}
/>,
);
expect(screen.getByRole("status", { name: "Thinking for 5s" })).toBeInTheDocument();
rerender(
<ThreadMessages
messages={[
{ ...prompt, deliveryStatus: "accepted" },
{
id: "t-optimistic",
role: "tool",
kind: "trace",
content: "web_search()",
traces: ["web_search()"],
turnId: "turn-optimistic",
turnPhase: "activity",
createdAt: now,
},
]}
isStreaming
activeTurnId="turn-optimistic"
runStartedAt={(now - 5_000) / 1000}
/>,
);
expect(screen.getByRole("button", { name: "Working for 5s" })).toBeInTheDocument();
});
it("does not move a mounted tail answer into offscreen rendering on the next turn", () => { it("does not move a mounted tail answer into offscreen rendering on the next turn", () => {
const completed: UIMessage[] = [ const completed: UIMessage[] = [
{ id: "u1", role: "user", content: "question", createdAt: 1 }, { id: "u1", role: "user", content: "question", createdAt: 1 },
+25
View File
@@ -120,6 +120,31 @@ describe("ThreadMotionCoordinator", () => {
}); });
}); });
it("reconciles observed layout growth before the next paint", () => {
const {
camera,
coordinator,
frames,
scheduler,
advanceFrame,
setGeometry,
} = motionHarness();
coordinator.resumeAutoFollow();
advanceFrame();
camera.jumpTo.mockClear();
scheduler.cancel.mockClear();
setGeometry({ scrollTop: 1_400, scrollHeight: 2_054 });
coordinator.invalidateGeometry();
coordinator.reconcileObservedGeometry();
expect(camera.jumpTo).toHaveBeenCalledWith(1_554);
expect(scheduler.cancel).toHaveBeenCalledTimes(1);
expect(frames).toHaveLength(0);
expect(coordinator.snapshot()).toMatchObject({ measurementPending: false });
});
it("pins repeated output growth on each authoritative geometry frame", () => { it("pins repeated output growth on each authoritative geometry frame", () => {
const { const {
camera, camera,
+31 -4
View File
@@ -241,6 +241,10 @@ describe("ThreadViewport", () => {
takeUserControl.mockClear(); takeUserControl.mockClear();
fireEvent.keyDown(disclosure, { key: "Enter" }); fireEvent.keyDown(disclosure, { key: "Enter" });
expect(takeUserControl).toHaveBeenCalledTimes(1); expect(takeUserControl).toHaveBeenCalledTimes(1);
takeUserControl.mockClear();
fireEvent.keyDown(disclosure, { key: " " });
expect(takeUserControl).toHaveBeenCalledTimes(1);
}); });
it("top-aligns short threads in the message rendering area", () => { it("top-aligns short threads in the message rendering area", () => {
@@ -653,7 +657,7 @@ describe("ThreadViewport", () => {
} }
}); });
it("coalesces streamed layout growth into frame-driven camera targets", async () => { it("settles observed streamed layout growth before paint", async () => {
const resizeObserver = stubResizeObserver(); const resizeObserver = stubResizeObserver();
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo") const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo")
.mockReturnValue("started"); .mockReturnValue("started");
@@ -740,10 +744,7 @@ describe("ThreadViewport", () => {
}); });
act(() => { act(() => {
contentObserver!.callback([], contentObserver as unknown as ResizeObserver); contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
contentObserver!.callback([], contentObserver as unknown as ResizeObserver);
}); });
expect(followTo).not.toHaveBeenCalled();
await flushAnimationFrame();
expect(followTo).toHaveBeenCalledTimes(1); expect(followTo).toHaveBeenCalledTimes(1);
expect(followTo).toHaveBeenLastCalledWith(1448); expect(followTo).toHaveBeenLastCalledWith(1448);
followTo.mockClear(); followTo.mockClear();
@@ -1959,6 +1960,12 @@ describe("ThreadViewport", () => {
it("waits for the next conversation's transcript before restoring its bottom", async () => { it("waits for the next conversation's transcript before restoring its bottom", async () => {
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo"); const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");
const handoffAnimation = {
cancel: vi.fn(),
oncancel: null,
onfinish: null,
} as unknown as Animation;
const animate = vi.fn(() => handoffAnimation);
const oldMessages: UIMessage[] = [ const oldMessages: UIMessage[] = [
{ {
id: "old-user", id: "old-user",
@@ -1998,6 +2005,7 @@ describe("ThreadViewport", () => {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 300 }, scrollTop: { configurable: true, writable: true, value: 300 },
animate: { configurable: true, value: animate },
}); });
jumpTo.mockClear(); jumpTo.mockClear();
@@ -2013,6 +2021,14 @@ describe("ThreadViewport", () => {
); );
expect(scroller.scrollTop).toBe(300); expect(scroller.scrollTop).toBe(300);
expect(jumpTo).not.toHaveBeenCalled(); expect(jumpTo).not.toHaveBeenCalled();
expect(animate).toHaveBeenCalledWith(
[{ opacity: 1 }, { opacity: 0.82 }],
{
duration: 80,
easing: "cubic-bezier(0.2, 0, 0, 1)",
fill: "forwards",
},
);
Object.defineProperty(scroller, "scrollHeight", { Object.defineProperty(scroller, "scrollHeight", {
configurable: true, configurable: true,
@@ -2033,6 +2049,17 @@ describe("ThreadViewport", () => {
await flushAnimationFrame(); await flushAnimationFrame();
expect(jumpTo.mock.calls).toEqual([[2400]]); expect(jumpTo.mock.calls).toEqual([[2400]]);
expect(followTo).toHaveBeenCalledWith(2400); expect(followTo).toHaveBeenCalledWith(2400);
expect(handoffAnimation.cancel).toHaveBeenCalled();
expect(animate).toHaveBeenCalledWith(
[{ opacity: 0.82 }, { opacity: 1 }],
{
duration: 140,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
);
expect(jumpTo.mock.invocationCallOrder[0]).toBeLessThan(
animate.mock.invocationCallOrder[1],
);
}); });
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => { it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {