Show recent Assistant sessions in empty chat

This commit is contained in:
rcourtman
2026-06-05 15:05:40 +01:00
parent 5559075864
commit bbef81274c
3 changed files with 131 additions and 2 deletions
@@ -108,6 +108,10 @@ runtime cost control, and shared AI transport surfaces.
persisted tool calls into the same transcript event shape used by live
streams so switching sessions does not hide prior tool evidence or collapse
the resumed conversation into a text-only transcript.
The empty Assistant drawer may surface recent non-empty sessions as direct
resume actions using the backend session list already owned by the drawer;
it must not create a parallel recent-chat store or product-authored prompt
shortcut path.
Assistant output hygiene is part of the same boundary: provider reasoning
and raw serialized tool-call artifacts must never render as assistant
transcript prose. Reasoning/thinking deltas may update neutral progress
@@ -2,6 +2,7 @@ import { Component, Show, For, createEffect, createMemo } from 'solid-js';
import { MessageItem } from './MessageItem';
import type { ChatSession } from '@/api/aiChat';
import type { ChatMessage, PendingApproval, PendingQuestion } from './types';
import { humanizeToken } from '@/utils/textPresentation';
interface ChatMessagesProps {
messages: ChatMessage[];
@@ -50,6 +51,23 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
);
});
const recentSessions = createMemo(() =>
(props.recentSessions || []).filter((session) => session.message_count > 0).slice(0, 3),
);
const formatSessionMessageCount = (count: number) =>
`${count} ${count === 1 ? 'message' : 'messages'}`;
const formatSessionHandoffLabel = (session: ChatSession) => {
const summary = session.handoff_summary;
if (!summary) return '';
const kind = summary.kind?.trim();
if (kind) return humanizeToken(kind);
if (summary.finding_id) return 'Patrol finding';
if (summary.run_id) return 'Patrol run';
return summary.has_model_context ? 'Context attached' : '';
};
// Auto-scroll to bottom on new messages or streaming content
createEffect(() => {
// Access the trigger to establish dependency (void suppresses unused var warning)
@@ -73,11 +91,46 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
<div ref={containerRef} class="flex-1 overflow-y-auto px-4 py-3 bg-surface">
{/* Empty state */}
<Show when={props.messages.length === 0 && props.emptyState}>
<div class="flex flex-col items-center justify-center min-h-full text-center py-8">
<div class="flex min-h-full flex-col items-center justify-center px-2 py-8 text-center">
<h3 class="text-base font-semibold text-base-content mb-3">{props.emptyState!.title}</h3>
<Show when={props.emptyState!.subtitle}>
<p class="text-sm text-muted max-w-xs">{props.emptyState!.subtitle}</p>
</Show>
<Show when={recentSessions().length > 0 && props.onLoadSession}>
<div class="mt-6 w-full max-w-sm text-left" aria-label="Recent Assistant sessions">
<div class="mb-2 text-[11px] font-semibold uppercase text-muted">Recent sessions</div>
<div class="space-y-1.5">
<For each={recentSessions()}>
{(session) => {
const handoffLabel = () => formatSessionHandoffLabel(session);
return (
<button
type="button"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-left transition-colors hover:border-blue-300 hover:bg-surface-alt focus:outline-none focus:ring-2 focus:ring-blue-500/30"
onClick={() => props.onLoadSession?.(session.id)}
aria-label={`Resume ${session.title || 'Untitled Assistant session'}`}
>
<div class="truncate text-sm font-medium text-base-content">
{session.title || 'Untitled'}
</div>
<div class="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-muted">
<span>{formatSessionMessageCount(session.message_count)}</span>
<Show when={handoffLabel()}>
{(label) => (
<>
<span aria-hidden="true">/</span>
<span class="truncate">{label()}</span>
</>
)}
</Show>
</div>
</button>
);
}}
</For>
</div>
</div>
</Show>
</div>
</Show>
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { ChatMessages } from '../ChatMessages';
import type { ChatMessage, PendingApproval, PendingQuestion } from '../types';
@@ -118,6 +118,78 @@ describe('ChatMessages', () => {
expect(screen.queryByText('Welcome')).not.toBeInTheDocument();
expect(screen.queryByText('Try asking')).not.toBeInTheDocument();
});
it('shows recent sessions as resume actions in the empty state', () => {
const onLoadSession = vi.fn();
render(() => (
<ChatMessages
messages={[]}
{...makeHandlers()}
emptyState={{ title: 'Ask about your infrastructure' }}
recentSessions={[
{
id: 'session-1',
title: 'Storage follow-up',
created_at: '',
updated_at: '',
message_count: 4,
handoff_summary: {
kind: 'patrol_finding',
finding_id: 'finding-1',
has_model_context: true,
},
},
{
id: 'session-2',
title: 'Router question',
created_at: '',
updated_at: '',
message_count: 1,
},
]}
onLoadSession={onLoadSession}
/>
));
expect(screen.getByLabelText('Recent Assistant sessions')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Resume Storage follow-up' })).toHaveTextContent(
'4 messages',
);
expect(screen.getByRole('button', { name: 'Resume Storage follow-up' })).toHaveTextContent(
'Patrol Finding',
);
expect(screen.getByRole('button', { name: 'Resume Router question' })).toHaveTextContent(
'1 message',
);
fireEvent.click(screen.getByRole('button', { name: 'Resume Storage follow-up' }));
expect(onLoadSession).toHaveBeenCalledWith('session-1');
});
it('does not show recent session resume actions when messages are present', () => {
render(() => (
<ChatMessages
messages={[makeMessage()]}
{...makeHandlers()}
emptyState={{ title: 'Ask about your infrastructure' }}
recentSessions={[
{
id: 'session-1',
title: 'Storage follow-up',
created_at: '',
updated_at: '',
message_count: 4,
},
]}
onLoadSession={vi.fn()}
/>
));
expect(screen.queryByLabelText('Recent Assistant sessions')).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Resume Storage follow-up' }),
).not.toBeInTheDocument();
});
});
describe('message rendering', () => {