diff --git a/ui/src/components/acp/sidebar.test.tsx b/ui/src/components/acp/sidebar.test.tsx new file mode 100644 index 0000000..e88cedb --- /dev/null +++ b/ui/src/components/acp/sidebar.test.tsx @@ -0,0 +1,89 @@ +import type React from 'react'; +import { describe, it, expect, vi } from 'vite-plus/test'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { Sidebar } from './sidebar'; + +vi.mock('@/components/brand-header', () => ({ + BrandHeader: ({ compact }: { compact?: boolean }) => ( +
{compact ? 'Brand compact' : 'Brand'}
+ ), +})); + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ + children, + render, + }: { + children?: React.ReactNode; + render?: React.ReactNode; + }) => <>{render ?? children}, +})); + +function createSpritz(name: string) { + return { + metadata: { name, namespace: 'default' }, + spec: { image: `example.com/${name}:latest` }, + status: { + phase: 'Ready', + acp: { state: 'ready' }, + }, + }; +} + +function createConversation(name: string, title: string, spritzName: string) { + return { + metadata: { name }, + spec: { + sessionId: `${name}-session`, + title, + spritzName, + }, + status: { + bindingState: 'active', + }, + }; +} + +const SidebarWithFocus = Sidebar as unknown as ( + props: React.ComponentProps & { + focusedSpritzName?: string | null; + }, +) => React.ReactElement; + +describe('Sidebar', () => { + it('moves the focused agent to the top, highlights it, and collapses other agents', () => { + render( + + + , + ); + + const agentHeaders = screen.getAllByRole('button', { name: / conversations$/i }); + expect(agentHeaders[0]?.getAttribute('aria-label')).toBe('beta conversations'); + expect(screen.getByRole('button', { name: 'beta conversations' }).getAttribute('aria-current')).toBe('true'); + expect(screen.getByRole('button', { name: 'beta conversations' }).getAttribute('aria-expanded')).toBe('true'); + expect(screen.getByRole('button', { name: 'alpha conversations' }).getAttribute('aria-expanded')).toBe('false'); + }); +}); diff --git a/ui/src/components/acp/sidebar.tsx b/ui/src/components/acp/sidebar.tsx index 0289961..8c3193c 100644 --- a/ui/src/components/acp/sidebar.tsx +++ b/ui/src/components/acp/sidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { PlusIcon, @@ -31,24 +31,46 @@ interface SidebarProps { onSelectConversation: (conversation: ConversationInfo) => void; onNewConversation: (spritzName: string) => void; creatingConversationFor?: string | null; + focusedSpritzName?: string | null; + focusedSpritz?: Spritz | null; collapsed: boolean; onToggleCollapse: () => void; mobileOpen: boolean; onCloseMobile: () => void; } +function sortAgentGroupsForFocus(groups: AgentGroup[], focusedSpritzName?: string | null): AgentGroup[] { + if (!focusedSpritzName) return groups; + return [...groups].sort((left, right) => { + const leftFocused = left.spritz.metadata.name === focusedSpritzName; + const rightFocused = right.spritz.metadata.name === focusedSpritzName; + if (leftFocused === rightFocused) return 0; + return leftFocused ? -1 : 1; + }); +} + export function Sidebar({ agents, selectedConversationId, onSelectConversation, onNewConversation, creatingConversationFor, + focusedSpritzName, + focusedSpritz, collapsed, onToggleCollapse, mobileOpen, onCloseMobile, }: SidebarProps) { - const firstAgentName = agents.length > 0 ? agents[0].spritz.metadata.name : null; + const orderedAgents = sortAgentGroupsForFocus(agents, focusedSpritzName); + const firstAgentName = orderedAgents.length > 0 ? orderedAgents[0].spritz.metadata.name : null; + const focusMode = Boolean(focusedSpritzName); + const focusedAgentInList = Boolean( + focusedSpritzName && orderedAgents.some((group) => group.spritz.metadata.name === focusedSpritzName), + ); + const showFocusedProvisioningSection = Boolean( + focusedSpritz && focusedSpritzName && !focusedAgentInList, + ); /* ── Collapsed desktop sidebar ── */ function renderCollapsed() { @@ -143,12 +165,15 @@ export function Sidebar({ {/* Conversation list */}
- {agents.length === 0 && ( + {showFocusedProvisioningSection && focusedSpritz && ( + + )} + {orderedAgents.length === 0 && !showFocusedProvisioningSection && (
No ACP-ready instances found.
)} - {agents.map((group) => ( + {orderedAgents.map((group) => ( { onSelectConversation(conv); close(); }} onNewConversation={onNewConversation} creatingConversationFor={creatingConversationFor} + defaultExpanded={!focusMode || group.spritz.metadata.name === focusedSpritzName} + focused={group.spritz.metadata.name === focusedSpritzName} /> ))}
@@ -189,6 +216,20 @@ export function Sidebar({ ); } +function FocusedAgentProvisioningSection({ spritz }: { spritz: Spritz }) { + const name = spritz.metadata.name; + const statusLine = String(spritz.status?.message || '').trim() + || [spritz.status?.phase, spritz.status?.acp?.state].filter(Boolean).join(' · ') + || 'Preparing chat'; + + return ( +
+
{name}
+
{statusLine}
+
+ ); +} + /* ── Agent section with animated expand/collapse ── */ function AgentSection({ @@ -197,17 +238,25 @@ function AgentSection({ onSelectConversation, onNewConversation, creatingConversationFor, + defaultExpanded, + focused, }: { group: AgentGroup; selectedConversationId: string | null; onSelectConversation: (conversation: ConversationInfo) => void; onNewConversation: (spritzName: string) => void; creatingConversationFor?: string | null; + defaultExpanded: boolean; + focused: boolean; }) { - const [expanded, setExpanded] = useState(true); + const [expanded, setExpanded] = useState(defaultExpanded); const name = group.spritz.metadata.name; const creatingForThisAgent = creatingConversationFor === name; + useEffect(() => { + setExpanded(defaultExpanded); + }, [defaultExpanded]); + return (
{/* Agent header */} @@ -215,8 +264,15 @@ function AgentSection({

- {selectedConversation?.spec?.title || selectedConversation?.metadata?.name || 'Select a conversation'} + {selectedConversation?.spec?.title + || selectedConversation?.metadata?.name + || focusedSpritz?.metadata?.name + || 'Select a conversation'}

{selectedConversation && (() => { const spritzName = selectedConversation.spec?.spritzName || ''; @@ -331,6 +439,11 @@ export function ChatPage() {

{parts.join(' · ')}

) : null; })()} + {!selectedConversation && provisioningSpritz && ( +

+ {provisioningSpritz.status?.message || 'Preparing the agent chat...'} +

+ )}
@@ -394,9 +507,23 @@ export function ChatPage() { {/* Messages area */}
{!selectedConversation ? ( -
- Select a conversation or create a new instance. -
+ provisioningSpritz ? ( +
+ Your agent is being created now +

+ We will start a chat automatically as soon as it is ready. +

+ {provisioningSpritz.status?.message && ( +

+ {provisioningSpritz.status.message} +

+ )} +
+ ) : ( +
+ Select a conversation or create a new instance. +
+ ) ) : transcript.messages.length === 0 ? (
Start a conversation