mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 11:46:28 +00:00
Show recent Assistant sessions in empty chat
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user