feat: workchat home screen (#217650)

## Summary

Workchat home screen. 

Used avatars as agent icons, works pretty nice imo

conversation history limited to 10 in the right column. 


<img width="1709" alt="Screenshot 2025-04-09 at 13 24 59"
src="https://github.com/user-attachments/assets/8f1fce7d-bace-4cd1-97de-0b0bc9c1b526"
/>

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jedr Blaszyk 2025-04-09 16:39:40 +02:00 committed by GitHub
parent 7e35e92b4b
commit 7951e7bca5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 343 additions and 79 deletions

View file

@ -1,70 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
EuiBasicTable,
EuiBasicTableColumn,
EuiLink,
EuiTitle,
EuiButton,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { Agent } from '../../../../common/agents';
import { useNavigation } from '../../hooks/use_navigation';
import { useAgentList } from '../../hooks/use_agent_list';
import { useCapabilities } from '../../hooks/use_capabilities';
import { appPaths } from '../../app_paths';
export const HomeAgentSection: React.FC<{}> = () => {
const { createWorkchatUrl, navigateToWorkchatUrl } = useNavigation();
const { agents } = useAgentList();
const { showManagement } = useCapabilities();
const columns: Array<EuiBasicTableColumn<Agent>> = [
{
field: 'name',
name: 'Name',
render: (value, agent) => {
return (
<EuiLink href={createWorkchatUrl(appPaths.chat.new({ agentId: agent.id }))}>
{value}
</EuiLink>
);
},
},
{ field: 'description', name: 'Description' },
{ field: 'user.name', name: 'Created by' },
];
return (
<KibanaPageTemplate.Section>
<EuiTitle size="s">
<h4>Your agents</h4>
</EuiTitle>
<EuiSpacer />
<EuiBasicTable columns={columns} items={agents} />
<EuiSpacer />
{showManagement && (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => {
navigateToWorkchatUrl(appPaths.agents.list);
}}
>
Go to agent management
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
)}
</KibanaPageTemplate.Section>
);
};

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
EuiLink,
EuiTitle,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiFlexGrid,
EuiText,
EuiAvatar,
EuiPanel,
EuiButtonIcon,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useNavigation } from '../../hooks/use_navigation';
import { useAgentList } from '../../hooks/use_agent_list';
import { appPaths } from '../../app_paths';
export const HomeAssistantsSection: React.FC<{}> = () => {
const { createWorkchatUrl, navigateToWorkchatUrl } = useNavigation();
const { agents } = useAgentList();
const assistantTiles = agents.map((assistant) => {
return (
<EuiPanel key={assistant.id} paddingSize="m" hasBorder={true}>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiAvatar size="l" name={assistant.name} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="gear"
color="text"
onClick={() => {
navigateToWorkchatUrl(appPaths.agents.edit({ agentId: assistant.id }));
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiLink
href={createWorkchatUrl(appPaths.chat.new({ agentId: assistant.id }))}
style={{ fontWeight: 'bold' }}
>
{assistant.name}
</EuiLink>
<EuiSpacer size="s" />
<EuiText size="xs" color="subdued">
{assistant.description}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
});
return (
<EuiFlexItem>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiIcon type="users" size="m" />
<EuiTitle size="xxs">
<h4>
{i18n.translate('workchatApp.home.assistants.title', {
defaultMessage: 'Assistants',
})}
</h4>
</EuiTitle>
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGrid columns={3}>{assistantTiles}</EuiFlexGrid>
<EuiSpacer />
</EuiFlexItem>
);
};

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import {
EuiLink,
EuiTitle,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiAvatar,
EuiPanel,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useNavigation } from '../../hooks/use_navigation';
import { useAgentList } from '../../hooks/use_agent_list';
import { useConversationList } from '../../hooks/use_conversation_list';
import { Agent } from '../../../../common/agents';
import { sortAndGroupConversations } from '../../utils/sort_and_group_conversations';
import { sliceRecentConversations } from '../../utils/slice_recent_conversations';
import { appPaths } from '../../app_paths';
export const HomeConversationHistorySection: React.FC<{}> = () => {
const { navigateToWorkchatUrl } = useNavigation();
const { agents } = useAgentList();
const { conversations } = useConversationList({});
const agentMap = useMemo<Record<string, Agent>>(() => {
return agents.reduce<Record<string, Agent>>((map, agent) => {
map[agent.id] = agent;
return map;
}, {});
}, [agents]);
const conversationGroups = useMemo(() => {
return sortAndGroupConversations(sliceRecentConversations(conversations, 10));
}, [conversations]);
const recentConversations = conversationGroups.map(
({ conversations: groupConversations, dateLabel }) => {
return (
<EuiFlexItem grow={false} key={dateLabel}>
<EuiPanel hasBorder={false} hasShadow={false} color="transparent" paddingSize="s">
<EuiText size="s">
<h4>{dateLabel}</h4>
</EuiText>
</EuiPanel>
<EuiFlexGroup direction={'column'}>
{groupConversations.map((conversation) => {
const agent = agentMap[conversation.agentId];
return (
<EuiFlexItem key={conversation.id}>
<EuiFlexGroup gutterSize="s" alignItems="center">
{agent && (
<EuiFlexItem grow={false}>
<EuiAvatar name={agent.name} size="s" />
</EuiFlexItem>
)}
<EuiFlexItem direction="column" grow={false}>
<EuiLink
onClick={() => {
navigateToWorkchatUrl(
appPaths.chat.conversation({
agentId: agent.id,
conversationId: conversation.id,
})
);
}}
>
{conversation.title}
</EuiLink>
<EuiText size="xs" onClick={() => {}}>
{agent.name}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
<EuiSpacer size="s" />
</EuiFlexItem>
);
}
);
return (
<EuiFlexItem grow={false} style={{ maxWidth: 400 }}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiIcon type="list" size="m" />
<EuiTitle size="xxs">
<h4>
{i18n.translate('workchatApp.home.recentConversations.title', {
defaultMessage: 'Recent conversations',
})}
</h4>
</EuiTitle>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup direction="column" gutterSize="s">
{recentConversations}
</EuiFlexGroup>
</EuiFlexItem>
);
};

View file

@ -7,16 +7,26 @@
import React from 'react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { HomeAgentSection } from './home_agent_section';
import { HomeIntegrationSection } from './home_integration_section';
import { EuiButtonEmpty, EuiFlexGroup } from '@elastic/eui';
import { HomeAssistantsSection } from './home_assistants_section';
import { HomeConversationHistorySection } from './home_conversation_history';
const headerButtons = [
<EuiButtonEmpty iconType={'questionInCircle'} color="primary" iconSide="left" href="/">
Learn more
</EuiButtonEmpty>,
];
export const WorkChatHomeView: React.FC<{}> = () => {
return (
<KibanaPageTemplate panelled data-test-subj="workChatHomePage">
<KibanaPageTemplate.Header pageTitle="WorkChat" />
<HomeAgentSection />
<HomeIntegrationSection />
<KibanaPageTemplate data-test-subj="workChatHomePage">
<KibanaPageTemplate.Header pageTitle="WorkChat" rightSideItems={headerButtons} />
<KibanaPageTemplate.Section>
<EuiFlexGroup gutterSize="l" alignItems="flexStart">
<HomeAssistantsSection />
<HomeConversationHistorySection />
</EuiFlexGroup>
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
);
};

View file

@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '../query_keys';
import { useWorkChatServices } from './use_workchat_service';
export const useConversationList = ({ agentId }: { agentId: string }) => {
export const useConversationList = ({ agentId }: { agentId?: string }) => {
const { conversationService } = useWorkChatServices();
const {
@ -17,7 +17,7 @@ export const useConversationList = ({ agentId }: { agentId: string }) => {
isLoading,
refetch: refresh,
} = useQuery({
queryKey: queryKeys.conversations.byAgent(agentId),
queryKey: agentId ? queryKeys.conversations.byAgent(agentId) : queryKeys.conversations.all,
queryFn: async () => {
return conversationService.list({ agentId });
},

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { sliceRecentConversations } from './slice_recent_conversations';
import type { ConversationSummary } from '../../../common/conversations';
describe('sliceRecentConversations', () => {
const createConversation = (id: string, lastUpdated: string): ConversationSummary => ({
id,
agentId: `agent-${id}`,
title: `Conversation ${id}`,
lastUpdated,
});
const conversations: ConversationSummary[] = [
createConversation('1', '2023-01-01T10:00:00Z'),
createConversation('2', '2023-01-02T10:00:00Z'),
createConversation('3', '2023-01-03T10:00:00Z'),
createConversation('4', '2023-01-04T10:00:00Z'),
createConversation('5', '2023-01-05T10:00:00Z'),
];
it('should return the most recent conversations based on the limit', () => {
const result = sliceRecentConversations(conversations, 3);
expect(result).toHaveLength(3);
expect(result[0].id).toBe('5');
expect(result[1].id).toBe('4');
expect(result[2].id).toBe('3');
});
it('should return all conversations if limit is greater than the number of conversations', () => {
const result = sliceRecentConversations(conversations, 10);
expect(result).toHaveLength(5);
expect(result[0].id).toBe('5');
expect(result[4].id).toBe('1');
});
it('should return conversations in descending order of lastUpdated date', () => {
const unsortedConversations: ConversationSummary[] = [
createConversation('3', '2023-01-03T10:00:00Z'),
createConversation('1', '2023-01-01T10:00:00Z'),
createConversation('5', '2023-01-05T10:00:00Z'),
createConversation('2', '2023-01-02T10:00:00Z'),
createConversation('4', '2023-01-04T10:00:00Z'),
];
const result = sliceRecentConversations(unsortedConversations, 5);
expect(result).toHaveLength(5);
expect(result[0].id).toBe('5');
expect(result[1].id).toBe('4');
expect(result[2].id).toBe('3');
expect(result[3].id).toBe('2');
expect(result[4].id).toBe('1');
});
it('should handle conversations with same lastUpdated date', () => {
const sameTimeConversations: ConversationSummary[] = [
createConversation('1', '2023-01-01T10:00:00Z'),
createConversation('2', '2023-01-01T10:00:00Z'),
createConversation('3', '2023-01-01T10:00:00Z'),
];
const result = sliceRecentConversations(sameTimeConversations, 2);
expect(result).toHaveLength(2);
});
it('should return empty array if conversations is empty', () => {
const result = sliceRecentConversations([], 3);
expect(result).toEqual([]);
});
it('should return empty array if conversations is undefined', () => {
const result = sliceRecentConversations(undefined as unknown as ConversationSummary[], 3);
expect(result).toEqual([]);
});
it('should return all conversations if limit is undefined', () => {
const result = sliceRecentConversations(conversations, undefined as unknown as number);
expect(result).toEqual(conversations);
});
});

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import type { ConversationSummary } from '../../../common/conversations';
/**
* Limits the conversations array to the most recent N conversations
*/
export const sliceRecentConversations = (
conversations: ConversationSummary[],
limit: number
): ConversationSummary[] => {
if (!limit || limit <= 0 || !conversations?.length) {
return conversations || [];
}
return conversations
.map((conversation) => {
return {
conversation,
date: moment(conversation.lastUpdated),
};
})
.sort((conv1, conv2) => {
return conv2.date.valueOf() - conv1.date.valueOf();
})
.slice(0, limit)
.map(({ conversation }) => conversation);
};