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
7 changes: 6 additions & 1 deletion ui/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function renderAtRoute(path: string) {
<Route index element={<div data-testid="chat-page">Chat Page</div>} />
<Route path="create" element={<div data-testid="create-page">Create Page</div>} />
<Route path="terminal/:name" element={<div data-testid="terminal-page">Terminal Page</div>} />
<Route path="c/:name?" element={<div data-testid="chat-page">Chat Page</div>} />
<Route path="c/*" element={<div data-testid="chat-page">Chat Page</div>} />
</Routes>
</NoticeProvider>
</ConfigProvider>
Expand All @@ -54,6 +54,11 @@ describe('App routing', () => {
expect(screen.getByTestId('chat-page')).toBeDefined();
});

it('renders ChatPage at /c/some-name/some-conversation (same route, no remount)', () => {
renderAtRoute('/c/some-name/some-conversation');
expect(screen.getByTestId('chat-page')).toBeDefined();
});

it('renders TerminalPage at /terminal/my-spritz', () => {
renderAtRoute('/terminal/my-spritz');
expect(screen.getByTestId('terminal-page')).toBeDefined();
Expand Down
5 changes: 2 additions & 3 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Layout } from '@/components/layout';
import { ChatPage } from '@/pages/chat';
import { CreatePage } from '@/pages/create';
import { TerminalPage } from '@/pages/terminal';
import { chatConversationRoutePath, chatRoutePath } from '@/lib/urls';
import { chatCatchAllRoutePath } from '@/lib/urls';

export function App() {
return (
Expand All @@ -19,8 +19,7 @@ export function App() {
<Route index element={<ChatPage />} />
<Route path="create" element={<CreatePage />} />
<Route path="terminal/:name" element={<TerminalPage />} />
<Route path={chatRoutePath(true)} element={<ChatPage />} />
<Route path={chatConversationRoutePath()} element={<ChatPage />} />
<Route path={chatCatchAllRoutePath()} element={<ChatPage />} />
</Route>
</Routes>
</NoticeProvider>
Expand Down
49 changes: 43 additions & 6 deletions ui/src/components/acp/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,18 @@ describe('Sidebar', () => {
);
});

it('moves the focused agent to the top, highlights it, and collapses other agents', () => {
it('keeps agents in alphabetical order regardless of focus and collapses non-focused agents', () => {
renderWithProviders(
<SidebarWithFocus
agents={[
{
spritz: createSpritz('alpha'),
conversations: [createConversation('alpha-conv', 'Alpha conversation', 'alpha')],
},
{
spritz: createSpritz('beta'),
conversations: [createConversation('beta-conv', 'Beta conversation', 'beta')],
},
{
spritz: createSpritz('alpha'),
conversations: [createConversation('alpha-conv', 'Alpha conversation', 'alpha')],
},
]}
selectedConversationId="beta-conv"
onSelectConversation={vi.fn()}
Expand All @@ -113,7 +113,9 @@ describe('Sidebar', () => {
const agentHeaders = screen.getAllByRole('button', {
name: / conversations$/i,
});
expect(agentHeaders[0]?.getAttribute('aria-label')).toBe('beta conversations');
// Alpha comes first alphabetically, even though beta is focused
expect(agentHeaders[0]?.getAttribute('aria-label')).toBe('alpha conversations');
expect(agentHeaders[1]?.getAttribute('aria-label')).toBe('beta conversations');
expect(
screen
.getByRole('button', { name: 'beta conversations' })
Expand All @@ -131,6 +133,41 @@ describe('Sidebar', () => {
).toBe('false');
});

it('sorts agents alphabetically even when passed in reverse order', () => {
renderWithProviders(
<Sidebar
agents={[
{
spritz: createSpritz('zulu'),
conversations: [createConversation('zulu-conv', 'Zulu conversation', 'zulu')],
},
{
spritz: createSpritz('alpha'),
conversations: [createConversation('alpha-conv', 'Alpha conversation', 'alpha')],
},
{
spritz: createSpritz('mike'),
conversations: [createConversation('mike-conv', 'Mike conversation', 'mike')],
},
]}
selectedConversationId={null}
onSelectConversation={vi.fn()}
onNewConversation={vi.fn()}
collapsed={false}
onToggleCollapse={vi.fn()}
mobileOpen={false}
onCloseMobile={vi.fn()}
/>,
);

const agentHeaders = screen.getAllByRole('button', {
name: / conversations$/i,
});
expect(agentHeaders[0]?.getAttribute('aria-label')).toBe('alpha conversations');
expect(agentHeaders[1]?.getAttribute('aria-label')).toBe('mike conversations');
expect(agentHeaders[2]?.getAttribute('aria-label')).toBe('zulu conversations');
});

it('shows a selected optimistic provisioning conversation for a focused route before the agent is discoverable', () => {
renderWithProviders(
<SidebarWithFocus
Expand Down
14 changes: 3 additions & 11 deletions ui/src/components/acp/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,6 @@ interface SidebarProps {
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,
Expand All @@ -72,7 +62,9 @@ export function Sidebar({
mobileOpen,
onCloseMobile,
}: SidebarProps) {
const orderedAgents = sortAgentGroupsForFocus(agents, focusedSpritzName);
const orderedAgents = [...agents].sort((a, b) =>
a.spritz.metadata.name.localeCompare(b.spritz.metadata.name),
);
const firstAgentName = orderedAgents.length > 0 ? orderedAgents[0].spritz.metadata.name : null;
const focusMode = Boolean(focusedSpritzName);
const focusedAgentInList = Boolean(
Expand Down
5 changes: 5 additions & 0 deletions ui/src/lib/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ export function chatConversationRoutePath(): string {
return `${chatRoutePrefixSegment()}/:name/:conversationId`;
}

/** Returns a single catch-all React Router path for all chat sub-routes. */
export function chatCatchAllRoutePath(): string {
return `${chatRoutePrefixSegment()}/*`;
}

export const hideRepoInputs = parseBoolean(config.repoDefaults.hideInputs, false);
export const defaultRepoUrl = String(config.repoDefaults.url || '').trim();
export const defaultRepoBranch = String(config.repoDefaults.branch || '').trim();
Expand Down
174 changes: 166 additions & 8 deletions ui/src/pages/chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,8 +454,7 @@ function renderChatPage(route: string, rawConfig?: RawSpritzConfig) {
<NoticeProvider>
<LocationDisplay />
<Routes>
<Route path="/c/:name/:conversationId" element={<ChatPage />} />
<Route path="/c/:name" element={<ChatPage />} />
<Route path="/c/*" element={<ChatPage />} />
<Route path="/" element={<ChatPage />} />
</Routes>
</NoticeProvider>
Expand Down Expand Up @@ -614,7 +613,7 @@ describe('ChatPage draft persistence', () => {
<ConfigProvider value={resolvedConfig}>
<NoticeProvider>
<Routes>
<Route path="/c/:name/:conversationId" element={<ChatPage />} />
<Route path="/c/*" element={<ChatPage />} />
</Routes>
</NoticeProvider>
</ConfigProvider>
Expand Down Expand Up @@ -665,7 +664,7 @@ describe('ChatPage draft persistence', () => {
<ConfigProvider value={config}>
<NoticeProvider>
<Routes>
<Route path="/c/:name/:conversationId" element={<ChatPage />} />
<Route path="/c/*" element={<ChatPage />} />
</Routes>
</NoticeProvider>
</ConfigProvider>
Expand Down Expand Up @@ -750,7 +749,7 @@ describe('ChatPage draft persistence', () => {
<ConfigProvider value={config}>
<NoticeProvider>
<Routes>
<Route path="/c/:name/:conversationId" element={<ChatPage />} />
<Route path="/c/*" element={<ChatPage />} />
</Routes>
</NoticeProvider>
</ConfigProvider>
Expand Down Expand Up @@ -785,7 +784,7 @@ describe('ChatPage draft persistence', () => {
<ConfigProvider value={config}>
<NoticeProvider>
<Routes>
<Route path="/c/:name/:conversationId" element={<ChatPage />} />
<Route path="/c/*" element={<ChatPage />} />
</Routes>
</NoticeProvider>
</ConfigProvider>
Expand Down Expand Up @@ -1104,7 +1103,7 @@ describe('ChatPage draft persistence', () => {
<ConfigProvider value={config}>
<NoticeProvider>
<Routes>
<Route path="/c/:name/:conversationId" element={<ChatPage />} />
<Route path="/c/*" element={<ChatPage />} />
</Routes>
</NoticeProvider>
</ConfigProvider>
Expand All @@ -1130,7 +1129,7 @@ describe('ChatPage draft persistence', () => {
<ConfigProvider value={config}>
<NoticeProvider>
<Routes>
<Route path="/c/:name/:conversationId" element={<ChatPage />} />
<Route path="/c/*" element={<ChatPage />} />
</Routes>
</NoticeProvider>
</ConfigProvider>
Expand Down Expand Up @@ -1338,3 +1337,162 @@ describe('ChatPage draft persistence', () => {
);
});
});

describe('ChatPage instance ordering', () => {
beforeEach(() => {
vi.useRealTimers();
Object.defineProperty(globalThis, 'localStorage', { value: createMockStorage(), writable: true });
Object.defineProperty(globalThis, 'sessionStorage', { value: createMockStorage(), writable: true });
Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', {
value: vi.fn(),
writable: true,
});
requestMock.mockReset();
sendPromptMock.mockReset();
showNoticeMock.mockReset();
refreshAuthTokenForWebSocketMock.mockClear();
resetACPMockState();
sendPromptMock.mockResolvedValue({});
});

it('sorts agents alphabetically in sidebar regardless of API return order', async () => {
const spritzZulu = createSpritz({ metadata: { name: 'zulu-instance' } });
const spritzAlpha = createSpritz({ metadata: { name: 'alpha-instance' } });
const convZulu = createConversation({
metadata: { name: 'conv-z' },
spec: { sessionId: 'sz', title: 'Zulu conv', spritzName: 'zulu-instance' },
});
const convAlpha = createConversation({
metadata: { name: 'conv-a' },
spec: { sessionId: 'sa', title: 'Alpha conv', spritzName: 'alpha-instance' },
});

requestMock.mockImplementation((path: string, options?: { method?: string }) => {
if (path === '/spritzes') {
// Return in reverse alphabetical order
return Promise.resolve({ items: [spritzZulu, spritzAlpha] });
}
if (path === '/acp/conversations?spritz=zulu-instance') {
return Promise.resolve({ items: [convZulu] });
}
if (path === '/acp/conversations?spritz=alpha-instance') {
return Promise.resolve({ items: [convAlpha] });
}
if (path.endsWith('/connect-ticket') && options?.method === 'POST') {
return Promise.resolve({
type: 'connect-ticket',
ticket: 'ticket-123',
expiresAt: '2026-03-30T12:34:56Z',
protocol: 'spritz-acp.v1',
connectPath: '/api/acp/conversations/conv-a/connect',
});
}
return Promise.resolve({});
});

renderChatPage('/c/alpha-instance/conv-a');

await waitFor(() => {
const order = screen.getByTestId('sidebar-agent-order');
expect(order.textContent).toBe('alpha-instance,zulu-instance');
});
});

it('keeps agent order stable when selecting a conversation from a different instance', async () => {
const spritzA = createSpritz({ metadata: { name: 'alpha-instance' } });
const spritzZ = createSpritz({ metadata: { name: 'zulu-instance' } });
const convA = createConversation({
metadata: { name: 'conv-a' },
spec: { sessionId: 'sa', title: 'Alpha conv', spritzName: 'alpha-instance' },
});
const convZ = createConversation({
metadata: { name: 'conv-z' },
spec: { sessionId: 'sz', title: 'Zulu conv', spritzName: 'zulu-instance' },
});

requestMock.mockImplementation((path: string, options?: { method?: string }) => {
if (path === '/spritzes') {
return Promise.resolve({ items: [spritzZ, spritzA] });
}
if (path === '/acp/conversations?spritz=alpha-instance') {
return Promise.resolve({ items: [convA] });
}
if (path === '/acp/conversations?spritz=zulu-instance') {
return Promise.resolve({ items: [convZ] });
}
if (path.endsWith('/connect-ticket') && options?.method === 'POST') {
return Promise.resolve({
type: 'connect-ticket',
ticket: 'ticket-123',
expiresAt: '2026-03-30T12:34:56Z',
protocol: 'spritz-acp.v1',
connectPath: '/api/acp/conversations/conv-a/connect',
});
}
return Promise.resolve({});
});

renderChatPage('/c/alpha-instance/conv-a');

await waitFor(() => {
expect(screen.getByTestId('sidebar-agent-order').textContent).toBe(
'alpha-instance,zulu-instance',
);
});

// Click a conversation from zulu-instance
await act(async () => {
fireEvent.click(screen.getByText('Zulu conv'));
});

// Agent order must remain alpha first, zulu second
expect(screen.getByTestId('sidebar-agent-order').textContent).toBe(
'alpha-instance,zulu-instance',
);
});

it('does not flicker selected conversation when creating a new one', async () => {
const spritz = createSpritz({ metadata: { name: 'covo' } });
const existingConv = createConversation({
metadata: { name: 'conv-existing' },
spec: { sessionId: 'se', title: 'Existing conv', spritzName: 'covo' },
});
const newConv = createConversation({
metadata: { name: 'conv-new' },
spec: { sessionId: 'sn', title: 'New conv', spritzName: 'covo' },
});

requestMock.mockImplementation((path: string, options?: { method?: string }) => {
if (path === '/spritzes') {
return Promise.resolve({ items: [spritz] });
}
if (path === '/acp/conversations?spritz=covo') {
return Promise.resolve({ items: [existingConv] });
}
if (path === '/acp/conversations' && options?.method === 'POST') {
return Promise.resolve(newConv);
}
if (path.endsWith('/connect-ticket') && options?.method === 'POST') {
return Promise.resolve({
type: 'connect-ticket',
ticket: 'ticket-123',
expiresAt: '2026-03-30T12:34:56Z',
protocol: 'spritz-acp.v1',
connectPath: '/api/acp/conversations/conv-existing/connect',
});
}
return Promise.resolve({});
});

renderChatPage('/c/covo/conv-existing');

await waitFor(() => {
expect(screen.getByTestId('selected-conversation').textContent).toBe('conv-existing');
});

// The selected conversation should never revert to conv-existing after switching
// (regression test: previously fetchAgents would re-run with stale URL params
// and reset selectedConversation back to the old one)
expect(screen.getByTestId('selected-conversation').textContent).toBe('conv-existing');
});
});
Loading
Loading