diff --git a/ui/src/components/acp/sidebar.test.tsx b/ui/src/components/acp/sidebar.test.tsx index e88cedb..8136339 100644 --- a/ui/src/components/acp/sidebar.test.tsx +++ b/ui/src/components/acp/sidebar.test.tsx @@ -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( + + + , + ); + + expect(screen.getByText('zeno-fresh-ridge')).toBeTruthy(); + expect(screen.getByText('Creating your agent instance.')).toBeTruthy(); + expect(screen.getByText('Starting…').closest('[aria-current="true"]')).toBeTruthy(); + }); }); diff --git a/ui/src/components/acp/sidebar.tsx b/ui/src/components/acp/sidebar.tsx index 8c3193c..8eb47d7 100644 --- a/ui/src/components/acp/sidebar.tsx +++ b/ui/src/components/acp/sidebar.tsx @@ -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'; @@ -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() { @@ -165,8 +169,11 @@ export function Sidebar({ {/* Conversation list */}
- {showFocusedProvisioningSection && focusedSpritz && ( - + {showFocusedProvisioningSection && focusedProvisioningSpritz && ( + )} {orderedAgents.length === 0 && !showFocusedProvisioningSection && (
@@ -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 ( -
-
{name}
-
{statusLine}
+
+
+
+
+
+
+
+
+
{statusLine}
+
); } diff --git a/ui/src/lib/provisioning.ts b/ui/src/lib/provisioning.ts new file mode 100644 index 0000000..f1318c2 --- /dev/null +++ b/ui/src/lib/provisioning.ts @@ -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; +} diff --git a/ui/src/pages/chat.test.tsx b/ui/src/pages/chat.test.tsx index c0ee6dc..8116b1f 100644 --- a/ui/src/pages/chat.test.tsx +++ b/ui/src/pages/chat.test.tsx @@ -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' }, diff --git a/ui/src/pages/chat.tsx b/ui/src/pages/chat.tsx index 2a4d27e..0ec97b5 100644 --- a/ui/src/pages/chat.tsx +++ b/ui/src/pages/chat.tsx @@ -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'; @@ -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; @@ -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 () => { @@ -441,7 +442,7 @@ export function ChatPage() { })()} {!selectedConversation && provisioningSpritz && (

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

)}
@@ -513,9 +514,9 @@ export function ChatPage() {

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

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

- {provisioningSpritz.status.message} + {provisioningStatusLine}

)}