Skip to content

Commit

Permalink
feat: update history list ui and implement delete / update /search (#4)
Browse files Browse the repository at this point in the history
* feat: update history list ui and implement delete / update /search

Signed-off-by: Lin Wang <wonglam@amazon.com>

* refactor: Address PR comments

Signed-off-by: Lin Wang <wonglam@amazon.com>

---------

Signed-off-by: Lin Wang <wonglam@amazon.com>
  • Loading branch information
wanglam authored and ruanyl committed Nov 20, 2023
1 parent 3fa5f53 commit 7ceee22
Show file tree
Hide file tree
Showing 8 changed files with 504 additions and 61 deletions.
60 changes: 60 additions & 0 deletions public/components/edit_conversation_name_modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useCallback, useRef } from 'react';

import { EuiConfirmModal, EuiFieldText, EuiSpacer, EuiText } from '@elastic/eui';
import { usePatchSession } from '../hooks/use_sessions';

interface EditConversationNameModalProps {
onClose?: (status: 'updated' | 'cancelled' | 'errored') => void;
sessionId: string;
defaultTitle: string;
}

export const EditConversationNameModal = ({
onClose,
sessionId,
defaultTitle,
}: EditConversationNameModalProps) => {
const titleInputRef = useRef<HTMLInputElement>(null);
const { loading, abortController, patchSession } = usePatchSession();

const handleCancel = useCallback(() => {
abortController?.abort();
onClose?.('cancelled');
}, [onClose, abortController]);
const handleConfirm = useCallback(async () => {
const title = titleInputRef.current?.value.trim();
if (!title) {
return;
}
try {
await patchSession(sessionId, title);
} catch (_e) {
onClose?.('errored');
return;
}
onClose?.('updated');
}, [onClose, sessionId, patchSession]);

return (
<EuiConfirmModal
title="Edit conversation name"
onCancel={handleCancel}
onConfirm={handleConfirm}
cancelButtonText="Cancel"
confirmButtonText="Confirm name"
confirmButtonDisabled={loading}
isLoading={loading}
>
<EuiText size="s">
<p>Please enter a new name for your conversation.</p>
</EuiText>
<EuiSpacer size="xs" />
<EuiFieldText inputRef={titleInputRef} defaultValue={defaultTitle} />
</EuiConfirmModal>
);
};
37 changes: 37 additions & 0 deletions public/hooks/fetch_reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,40 @@ export const genericReducer: GenericReducer = (state, action) => {
return state;
}
};

interface StateWithAbortController<T> {
data?: T;
loading: boolean;
error?: Error;
abortController?: AbortController;
}

type ActionWithAbortController<T> =
| { type: 'request'; abortController: AbortController }
| { type: 'success'; payload: State<T>['data'] }
| {
type: 'failure';
error: NonNullable<State<T>['error']> | { body: NonNullable<State<T>['error']> };
};

// TODO use instantiation expressions when typescript is upgraded to >= 4.7
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type GenericReducerWithAbortController<T = any> = Reducer<
StateWithAbortController<T>,
ActionWithAbortController<T>
>;
export const genericReducerWithAbortController: GenericReducerWithAbortController = (
state,
action
) => {
switch (action.type) {
case 'request':
return { data: state.data, loading: true, abortController: action.abortController };
case 'success':
return { loading: false, data: action.payload };
case 'failure':
return { loading: false, error: 'body' in action.error ? action.error.body : action.error };
default:
return state;
}
};
49 changes: 47 additions & 2 deletions public/hooks/use_sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { useEffect, useReducer, useState } from 'react';
import { useCallback, useEffect, useReducer, useState } from 'react';
import { HttpFetchQuery, SavedObjectsFindOptions } from '../../../../src/core/public';
import { ASSISTANT_API } from '../../common/constants/llm';
import { ISession, ISessionFindResponse } from '../../common/types/chat_saved_object_attributes';
import { useChatContext } from '../contexts/chat_context';
import { useCore } from '../contexts/core_context';
import { GenericReducer, genericReducer } from './fetch_reducer';
import { GenericReducer, genericReducer, genericReducerWithAbortController } from './fetch_reducer';

export const useGetSession = () => {
const chatContext = useChatContext();
Expand Down Expand Up @@ -65,3 +65,48 @@ export const useGetSessions = (options: Partial<SavedObjectsFindOptions> = {}) =

return { ...state, refresh: () => setRefresh({}) };
};

export const useDeleteSession = () => {
const core = useCore();
const [state, dispatch] = useReducer(genericReducerWithAbortController, { loading: false });

const deleteSession = useCallback((sessionId: string) => {
const abortController = new AbortController();
dispatch({ type: 'request', abortController });
return core.services.http
.delete(`${ASSISTANT_API.SESSION}/${sessionId}`, {
signal: abortController.signal,
})
.then((payload) => dispatch({ type: 'success', payload }))
.catch((error) => dispatch({ type: 'failure', error }));
}, []);

return {
...state,
deleteSession,
};
};

export const usePatchSession = () => {
const core = useCore();
const [state, dispatch] = useReducer(genericReducerWithAbortController, { loading: false });

const patchSession = useCallback((sessionId: string, title: string) => {
const abortController = new AbortController();
dispatch({ type: 'request', abortController });
return core.services.http
.patch(`${ASSISTANT_API.SESSION}/${sessionId}`, {
query: {
title,
},
signal: abortController.signal,
})
.then((payload) => dispatch({ type: 'success', payload }))
.catch((error) => dispatch({ type: 'failure', error }));
}, []);

return {
...state,
patchSession,
};
};
132 changes: 132 additions & 0 deletions public/tabs/history/chat_history_list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useCallback } from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiLink,
EuiPanel,
EuiText,
} from '@elastic/eui';
import moment from 'moment';

interface ChatHistory {
id: string;
title: string;
updatedTimeMs: number;
}

interface ChatHistoryListItemProps extends ChatHistory {
hasBottomBorder?: boolean;
onTitleClick?: (id: string, title: string) => void;
onDeleteClick?: (conversation: { id: string }) => void;
onEditClick?: (conversation: { id: string; title: string }) => void;
}

export const ChatHistoryListItem = ({
id,
title,
updatedTimeMs,
hasBottomBorder = true,
onTitleClick,
onDeleteClick,
onEditClick,
}: ChatHistoryListItemProps) => {
const handleTitleClick = useCallback(() => {
onTitleClick?.(id, title);
}, [onTitleClick, id, title]);

const handleDeleteClick = useCallback(() => {
onDeleteClick?.({ id });
}, [onDeleteClick, id]);

const handleEditClick = useCallback(() => {
onEditClick?.({ id, title });
}, [onEditClick, id, title]);

return (
<>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem>
<EuiLink onClick={handleTitleClick}>
<EuiText size="s">
<p
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{title}
</p>
</EuiText>
</EuiLink>
<EuiText size="s" color="subdued">
{moment(updatedTimeMs).format('MMMM D, YYYY')} at{' '}
{moment(updatedTimeMs).format('h:m A')}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButtonIcon
onClick={handleEditClick}
iconType="pencil"
color="ghost"
aria-label="Edit conversation name"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
onClick={handleDeleteClick}
iconType="trash"
color="danger"
aria-label="Delete conversation"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{hasBottomBorder && <EuiHorizontalRule />}
</>
);
};

export interface ChatHistoryListProps {
chatHistories: ChatHistory[];
onChatHistoryTitleClick?: (id: string, title: string) => void;
onChatHistoryDeleteClick?: (conversation: { id: string }) => void;
onChatHistoryEditClick?: (conversation: { id: string; title: string }) => void;
}

export const ChatHistoryList = ({
chatHistories,
onChatHistoryTitleClick,
onChatHistoryEditClick,
onChatHistoryDeleteClick,
}: ChatHistoryListProps) => {
return (
<>
<EuiPanel hasBorder hasShadow>
{chatHistories.map((item, index) => (
<ChatHistoryListItem
key={item.id}
id={item.id}
title={item.title}
updatedTimeMs={item.updatedTimeMs}
hasBottomBorder={index + 1 < chatHistories.length}
onTitleClick={onChatHistoryTitleClick}
onEditClick={onChatHistoryEditClick}
onDeleteClick={onChatHistoryDeleteClick}
/>
))}
</EuiPanel>
</>
);
};
Loading

0 comments on commit 7ceee22

Please sign in to comment.