Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions ui/src/components/acp/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,27 @@ describe('Sidebar', () => {
expect(screen.getByRole('button', { name: 'beta conversations' }).getAttribute('aria-expanded')).toBe('true');
expect(screen.getByRole('button', { name: 'alpha conversations' }).getAttribute('aria-expanded')).toBe('false');
});

it('shows a selected optimistic provisioning conversation for a focused route before the agent is discoverable', () => {
render(
<MemoryRouter>
<SidebarWithFocus
agents={[]}
selectedConversationId={null}
onSelectConversation={vi.fn()}
onNewConversation={vi.fn()}
collapsed={false}
onToggleCollapse={vi.fn()}
mobileOpen={false}
onCloseMobile={vi.fn()}
focusedSpritzName="zeno-fresh-ridge"
focusedSpritz={null}
/>
</MemoryRouter>,
);

expect(screen.getByText('zeno-fresh-ridge')).toBeTruthy();
expect(screen.getByText('Creating your agent instance.')).toBeTruthy();
expect(screen.getByText('Starting…').closest('[aria-current="true"]')).toBeTruthy();
});
});
57 changes: 45 additions & 12 deletions ui/src/components/acp/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import {
PencilIcon,
LayoutGridIcon,
ChevronRightIcon,
LoaderCircleIcon,
} from 'lucide-react';
import { cn, timeAgo } from '@/lib/utils';
import { describeChatAction } from '@/lib/urls';
import { buildProvisioningPlaceholderSpritz, getProvisioningStatusLine } from '@/lib/provisioning';
import { BrandHeader } from '@/components/brand-header';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import type { ConversationInfo } from '@/types/acp';
Expand Down Expand Up @@ -68,9 +71,10 @@ export function Sidebar({
const focusedAgentInList = Boolean(
focusedSpritzName && orderedAgents.some((group) => group.spritz.metadata.name === focusedSpritzName),
);
const showFocusedProvisioningSection = Boolean(
focusedSpritz && focusedSpritzName && !focusedAgentInList,
);
const focusedProvisioningSpritz = focusedSpritzName && !focusedAgentInList
? focusedSpritz || buildProvisioningPlaceholderSpritz(focusedSpritzName)
: null;
const showFocusedProvisioningSection = Boolean(focusedProvisioningSpritz);

/* ── Collapsed desktop sidebar ── */
function renderCollapsed() {
Expand Down Expand Up @@ -165,8 +169,11 @@ export function Sidebar({

{/* Conversation list */}
<div role="list" aria-label="Conversations" className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{showFocusedProvisioningSection && focusedSpritz && (
<FocusedAgentProvisioningSection spritz={focusedSpritz} />
{showFocusedProvisioningSection && focusedProvisioningSpritz && (
<FocusedAgentProvisioningSection
spritz={focusedProvisioningSpritz}
selectedConversationId={selectedConversationId}
/>
)}
{orderedAgents.length === 0 && !showFocusedProvisioningSection && (
<div className="p-6 text-center text-xs text-muted-foreground">
Expand Down Expand Up @@ -216,16 +223,42 @@ export function Sidebar({
);
}

function FocusedAgentProvisioningSection({ spritz }: { spritz: Spritz }) {
function FocusedAgentProvisioningSection({
spritz,
selectedConversationId,
}: {
spritz: Spritz;
selectedConversationId: string | null;
}) {
const name = spritz.metadata.name;
const statusLine = String(spritz.status?.message || '').trim()
|| [spritz.status?.phase, spritz.status?.acp?.state].filter(Boolean).join(' · ')
|| 'Preparing chat';
const statusLine = getProvisioningStatusLine(spritz);
const conversationLabel = describeChatAction(spritz).label;
const conversationSelected = !selectedConversationId;

return (
<div role="listitem" className="rounded-[var(--radius-lg)] border border-sidebar-border bg-sidebar-accent px-3 py-2">
<div className="text-xs font-medium text-foreground" aria-current="true">{name}</div>
<div className="mt-1 text-xs text-muted-foreground">{statusLine}</div>
<div role="listitem" className="flex flex-col gap-0.5">
<div className="flex items-center gap-1">
<div
aria-current="true"
className="flex flex-1 items-center gap-2 rounded-[var(--radius-lg)] bg-sidebar-accent px-3 py-1.5 text-left text-xs font-medium text-foreground"
>
<ChevronRightIcon aria-hidden="true" className="size-3 shrink-0 rotate-90" />
<span className="truncate">{name}</span>
</div>
</div>
<div className="flex flex-col gap-1">
<div
aria-current={conversationSelected ? 'true' : undefined}
className={cn(
'ml-8 flex items-center gap-2 rounded-[var(--radius-lg)] px-3 py-1.5 text-[13px] text-foreground',
conversationSelected ? 'bg-sidebar-accent' : 'bg-transparent',
)}
>
<LoaderCircleIcon aria-hidden="true" className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
<span className="truncate">{conversationLabel}</span>
</div>
<div className="px-11 text-xs text-muted-foreground">{statusLine}</div>
</div>
</div>
);
}
Expand Down
38 changes: 38 additions & 0 deletions ui/src/lib/provisioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Spritz } from '@/types/spritz';

export const DEFAULT_PROVISIONING_MESSAGE = 'Creating your agent instance.';

export function isSpritzChatReady(spritz: Spritz | null | undefined): boolean {
if (!spritz) return false;
return spritz.status?.phase === 'Ready' && spritz.status?.acp?.state === 'ready';
}

export function buildProvisioningPlaceholderSpritz(name: string): Spritz {
const normalizedName = String(name || '').trim();
return {
metadata: {
name: normalizedName,
namespace: '',
},
spec: {
image: '',
},
status: {
phase: 'Provisioning',
message: DEFAULT_PROVISIONING_MESSAGE,
acp: {
state: 'starting',
},
},
};
}

export function getProvisioningStatusLine(spritz: Spritz | null | undefined): string {
const message = String(spritz?.status?.message || '').trim();
if (message) return message;
const composite = [spritz?.status?.phase, spritz?.status?.acp?.state]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' · ');
return composite || DEFAULT_PROVISIONING_MESSAGE;
}
20 changes: 20 additions & 0 deletions ui/src/pages/chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,26 @@ describe('ChatPage draft persistence', () => {
expect(screen.queryByText('Select a conversation or create a new instance.')).toBeNull();
});

it('keeps the provisioning route visible while the spritz resource is not discoverable yet', async () => {
requestMock.mockImplementation((path: string) => {
if (path === '/spritzes') {
return Promise.resolve({ items: [] });
}
if (path === '/spritzes/zeno-fresh-ridge') {
return Promise.reject(new Error('Not found.'));
}
return Promise.resolve({});
});

renderChatPage('/c/zeno-fresh-ridge');

expect(await screen.findByText('Your agent is being created now')).toBeTruthy();
expect(screen.getByText('We will start a chat automatically as soon as it is ready.')).toBeTruthy();
expect(screen.getAllByText('Creating your agent instance.').length).toBeGreaterThan(0);
expect(screen.getByTestId('sidebar-focused-spritz').textContent).toBe('zeno-fresh-ridge');
expect(screen.queryByText('Select a conversation or create a new instance.')).toBeNull();
});

it('automatically creates and opens a conversation once a provisioning agent becomes ready', async () => {
const createdConversation = createConversation({
metadata: { name: 'conv-created' },
Expand Down
19 changes: 10 additions & 9 deletions ui/src/pages/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { useConfig } from '@/lib/config';
import { useChatConnection } from '@/lib/use-chat-connection';
import { readChatDraft, writeChatDraft, clearChatDraft } from '@/lib/chat-draft';
import { buildFallbackConversationTitle, hasDurableConversationTitle } from '@/lib/conversation-title';
import {
buildProvisioningPlaceholderSpritz,
getProvisioningStatusLine,
isSpritzChatReady,
} from '@/lib/provisioning';
import { chatConversationPath } from '@/lib/urls';
import { useNotice } from '@/components/notice-banner';
import { Sidebar } from '@/components/acp/sidebar';
Expand All @@ -29,11 +34,6 @@ interface AgentGroup {

const PROVISIONING_POLL_INTERVAL_MS = 2000;

function isSpritzChatReady(spritz: Spritz | null | undefined): boolean {
if (!spritz) return false;
return spritz.status?.phase === 'Ready' && spritz.status?.acp?.state === 'ready';
}

function getConversationActivityTime(conversation: ConversationInfo): number {
const raw = String(conversation.status?.lastActivityAt || '').trim();
if (!raw) return Number.NEGATIVE_INFINITY;
Expand Down Expand Up @@ -77,11 +77,12 @@ export function ChatPage() {
const selectedSpritzName = selectedConversation?.spec?.spritzName || name || '';
const selectedConversationId = selectedConversation?.metadata?.name || '';
const focusedSpritz = name
? spritzes.find((spritz) => spritz.metadata.name === name) || null
? spritzes.find((spritz) => spritz.metadata.name === name) || buildProvisioningPlaceholderSpritz(name)
: null;
const provisioningSpritz = focusedSpritz && !isSpritzChatReady(focusedSpritz)
? focusedSpritz
: null;
const provisioningStatusLine = getProvisioningStatusLine(provisioningSpritz);

// Fetch agents and conversations
const fetchAgents = useCallback(async () => {
Expand Down Expand Up @@ -441,7 +442,7 @@ export function ChatPage() {
})()}
{!selectedConversation && provisioningSpritz && (
<p className="m-0 truncate text-xs opacity-60">
{provisioningSpritz.status?.message || 'Preparing the agent chat...'}
{provisioningStatusLine}
</p>
)}
</div>
Expand Down Expand Up @@ -513,9 +514,9 @@ export function ChatPage() {
<p className="m-0 text-sm text-muted-foreground">
We will start a chat automatically as soon as it is ready.
</p>
{provisioningSpritz.status?.message && (
{provisioningStatusLine && (
<p className="m-0 text-xs text-muted-foreground">
{provisioningSpritz.status.message}
{provisioningStatusLine}
</p>
)}
</div>
Expand Down
Loading