mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 16:19:17 +03:00
feat(webui): polish sidebar and session transitions (#5393)
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user