diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e7ff6..e323c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 0.4.0 + +This is a breaking change and requires the backend with version 0.4.x. + +## What's new + +* **[breaking]** Pin and unpin chats from the chat actions menu. Pinned chats appear above the normal list. +* Order chat list by the latest successful update. +* Support "duplicate to create" for model configs. + # 0.3.0 This is a breaking change and requires the backend with version 0.3.x. diff --git a/README.md b/README.md index aa93105..1294e5e 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,25 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# TinyWebUI -## Getting Started +A lightweight chat interface built with Next.js and React. Requires the matching TinyWebUI server. -First, run the development server: +## How to Use -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Interactive Dev Demos - -Development pages for manually exploring the components without a backend. - -Run the dev server and open: - -``` -npm run dev -``` - -Entry points: - -* Side bar - -http://localhost:3000/dev/side-demo +Open your deployment, sign in, select a model, and start chatting. Administrators can configure model providers in Settings. -* User input +Use each chat's actions menu to rename, pin, unpin, or delete it. Pinned chats appear first; both lists are ordered by the latest successful message write. -http://localhost:3000/dev/user-input-demo +## How to Build -* Message (single message preview) +Use a current Node.js LTS release and npm. From the repository root: -http://localhost:3000/dev/message-demo - -These pages are purely for local manual testing and are not intended for production deployment. - -### Static Export Without Dev Pages - -When generating a static bundle (e.g. deploying to a plain static host), use: - -``` -npm run build:static +```bash +git submodule update --init --recursive +npm install +npm run build ``` -This script will: -1. Temporarily move `src/app/dev` to `.dev-pages.stash` before `next build`. -2. Run `next build` (which emits a fully static site to `out/` because of `output: 'export'` in the config). -3. Restore the dev pages directory afterward so local development can continue. - -Artifacts `.dev-pages.stash` and marker `.dev-pages-removed` are git‑ignored. - -Result: No `/dev/*` HTML or assets are included in the exported static site. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel +The build generates static files in `out/`. Serve them with a reverse proxy that routes `/api` WebSocket traffic to the backend on the same origin. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +For local development, run `npm run dev` (port 3000). Keep the same `/api` proxy arrangement, routing other requests to the dev server. -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Run `npm test` for the app and SDK tests. diff --git a/src/app/chat/chat-title.tsx b/src/app/chat/chat-title.tsx index d4b7c55..551943f 100644 --- a/src/app/chat/chat-title.tsx +++ b/src/app/chat/chat-title.tsx @@ -1,17 +1,20 @@ "use client"; import React from "react"; +import { MoreHorizontal, Pin } from "lucide-react"; export interface ChatTitleProps { id?: string; active: boolean; title: string; + pinned?: boolean; onChatSelected: (id: string | undefined) => void; onContextMenu?: (event: React.MouseEvent) => void; + onMenuClick?: (event: React.MouseEvent) => void; } export const ChatTitle = React.forwardRef( - function ChatTitleInner({ id, active, title, onChatSelected, onContextMenu }, ref) { + function ChatTitleInner({ id, active, title, pinned, onChatSelected, onContextMenu, onMenuClick }, ref) { return (
( onContextMenu(event); } }} - className={`flex items-center px-3 py-2 rounded-md cursor-pointer text-sm transition-colors ${ + className={`flex min-w-0 items-center px-3 py-2 rounded-md cursor-pointer text-sm transition-colors ${ active ? "bg-primary text-primary-foreground" : "hover:bg-accent hover:text-accent-foreground" }`} > + {pinned && } {title} + {onMenuClick && ( + + )}
); } diff --git a/src/app/chat/chat.tsx b/src/app/chat/chat.tsx index 370637e..ab7dd9c 100644 --- a/src/app/chat/chat.tsx +++ b/src/app/chat/chat.tsx @@ -21,7 +21,7 @@ type ToolContext = ListFilesToolContext & QuickJSToolContext; interface ChatProps { onCreateChat: (chatId: string, message: ServerTypes.Message, attachedFiles: AttachedFile[]) => void; onSetChatTitle: (chatId: string, title: string) => void; - requestChatListUpdateAsync?: () => Promise; + requestChatListUpdateAsync?: (fromStart?: boolean) => Promise; activeChatId?: string; selectedModelId?: string; titleGenerationModelId?: string; @@ -174,7 +174,7 @@ export function Chat({ if (!(error instanceof RequestError) || error.code !== ErrorCode.CONFLICT) { throw error; } - await requestChatListUpdateAsync?.(); + await requestChatListUpdateAsync?.(true); chatId = await TUIClientSingleton.get().newChatAsync(); } onCreateChat(chatId, message, attachedFiles); @@ -249,6 +249,7 @@ export function Chat({ } if (result.done) { parentForNextCall = result.value.messageIds[result.value.messageIds.length - 1]; + await requestChatListUpdateAsync?.(true).catch(console.error); break; } else { if (typeof result.value === "string") { diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index b3d228f..f8d4ae1 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -13,12 +13,18 @@ import { Logo } from "@/components/custom/logo"; const CHAT_LIST_MARGIN = 50; +type ChatLists = { + chats: ServerTypes.GetChatListResult; + pinned: ServerTypes.GetPinnedChatListResult; +}; + export default function ChatPage() { const [activeChatId, setActiveChatId] = useState(undefined); const [isSidebarVisible, setIsSidebarVisible] = useState(true); const [selectedModelId, setSelectedModelId] = useState(undefined); const [titleGenerationModelId, setTitleGenerationModelId] = useState(undefined); - const [chatList, setChatList] = useState([]); + const chatListsRef = useRef({ chats: [], pinned: [] }); + const [chatLists, setChatLists] = useState(chatListsRef.current); const [initialized, setInitialized] = useState(false); const [newChatUserMessage, setNewChatUserMessage] = useState(undefined); const [newChatAttachedFiles, setNewChatAttachedFiles] = useState([]); @@ -26,8 +32,14 @@ export default function ChatPage() { /** The index of the last chat displayed. -1 if none is displayed */ const maxDisplayedChatIndex = useRef(-1); const updateChatListPromise = useRef|undefined>(undefined); + const refreshChatListRequested = useRef(false); const scrollPositions = useRef>({}); + const updateChatLists = useCallback((updater: (current: ChatLists) => ChatLists) => { + chatListsRef.current = updater(chatListsRef.current); + setChatLists(chatListsRef.current); + }, []); + const onSwitchChat = useCallback((chatId: string | undefined) => { setActiveChatId(chatId); setNewChatUserMessage(undefined); @@ -42,24 +54,27 @@ export default function ChatPage() { const chatInfo = { id: chatId }; - setChatList(prev => [chatInfo, ...prev]); + updateChatLists(current => ({ ...current, chats: [chatInfo, ...current.chats] })); + refreshChatListRequested.current = true; setActiveChatId(chatId); setNewChatUserMessage(message); setNewChatAttachedFiles(attachedFiles); - }, []); + }, [updateChatLists]); const onDeleteChat = useCallback((chatId: string) => { - setChatList(prevList => { - return prevList.filter(chat => chat.id !== chatId); - }); + updateChatLists(current => ({ + chats: current.chats.filter(chat => chat.id !== chatId), + pinned: current.pinned.filter(chat => chat.id !== chatId), + })); + refreshChatListRequested.current = true; if (activeChatId === chatId) { setActiveChatId(undefined); } - }, [activeChatId]); + }, [activeChatId, updateChatLists]); const onSetChatTitle = useCallback((chatId: string, title: string) => { - setChatList(prevList => { - return prevList.map(chat => { + const updateTitle = (list: ServerTypes.GetChatListResult) => { + return list.map(chat => { if (chat.id === chatId) { return { ...chat, @@ -72,8 +87,13 @@ export default function ChatPage() { return chat; } }); - }); - }, []); + }; + updateChatLists(current => ({ + chats: updateTitle(current.chats), + pinned: updateTitle(current.pinned), + })); + refreshChatListRequested.current = true; + }, [updateChatLists]); const onScrollPositionChange = useCallback((scrollTop: number) => { if (activeChatId) { @@ -85,22 +105,29 @@ export default function ChatPage() { /** Allow two trials in case of resource conflict */ for (let trial = 0; trial < 2; trial++) { try { - const oldList = JSON.parse(JSON.stringify(chatList)) as ServerTypes.GetChatListResult; - const start = fromStart ? 0 : chatList.length; - const segment = await TUIClientSingleton.get().getChatListAsync({ - start: start, - quantity: fromStart ? maxDisplayedChatIndex.current + 1 + CHAT_LIST_MARGIN : CHAT_LIST_MARGIN, - metaDataKeys: ["title"], - }); - const newList = fromStart ? segment : [...oldList, ...segment]; - setChatList(newList); - if (newList.find(c => c.id === activeChatId) === undefined) { - /** The active chat was deleted */ - onSwitchChat(undefined); + const client = TUIClientSingleton.get(); + const pinned = await client.getPinnedChatListAsync({ metaDataKeys: ["title"] }); + const pinnedIds = new Set(pinned.map(chat => chat.id)); + const chats = fromStart ? [] : [...chatListsRef.current.chats]; + let quantity = fromStart + ? Math.max(chatListsRef.current.chats.length, maxDisplayedChatIndex.current + 1 + CHAT_LIST_MARGIN) + : CHAT_LIST_MARGIN; + while (true) { + const segment = await client.getChatListAsync({ + start: chats.length, + quantity, + metaDataKeys: ["title"], + }); + chats.push(...segment); + if (segment.length < quantity || segment.some(chat => !pinnedIds.has(chat.id))) { + break; + } + quantity = CHAT_LIST_MARGIN; } + updateChatLists(() => ({ chats, pinned })); return; } catch (error) { - if (error instanceof RequestError && (error.code === ErrorCode.CONFLICT)) { + if (trial === 0 && error instanceof RequestError && error.code === ErrorCode.CONFLICT) { /** Retry from the start */ fromStart = true; } else { @@ -108,14 +135,28 @@ export default function ChatPage() { } } } - }, [chatList, activeChatId, onSwitchChat]); + }, [updateChatLists]); - const updateChatListDedupAsync = useCallback(async () => { - if (updateChatListPromise.current === undefined) { - updateChatListPromise.current = updateChatListAsync(); + const updateChatListDedupAsync = useCallback(async (fromStart = false) => { + if (fromStart) { + refreshChatListRequested.current = true; + } + if (updateChatListPromise.current !== undefined) { + await updateChatListPromise.current; + return; + } + updateChatListPromise.current = (async () => { + do { + const refresh = refreshChatListRequested.current; + refreshChatListRequested.current = false; + await updateChatListAsync(refresh); + } while (refreshChatListRequested.current); + })(); + try { + await updateChatListPromise.current; + } finally { + updateChatListPromise.current = undefined; } - await updateChatListPromise.current; - updateChatListPromise.current = undefined; }, [updateChatListAsync]); useEffect(() => { @@ -151,6 +192,9 @@ export default function ChatPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const pinnedChatIds = new Set(chatLists.pinned.map(chat => chat.id)); + const chatList = [...chatLists.pinned, ...chatLists.chats.filter(chat => !pinnedChatIds.has(chat.id))]; + if (!initialized) { return (
@@ -177,6 +221,7 @@ export default function ChatPage() { onSetChatTitle={onSetChatTitle} onDeleteChat={onDeleteChat} chatList={chatList} + pinnedChatIds={pinnedChatIds} activeChatId={activeChatId} onHideSidebar={() => setIsSidebarVisible(false)} /> diff --git a/src/app/chat/side.tsx b/src/app/chat/side.tsx index 2e7fb94..0c253e3 100644 --- a/src/app/chat/side.tsx +++ b/src/app/chat/side.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useCallback, useState, MouseEvent, useLayoutEffect } from "react"; -import { PanelLeftClose } from "lucide-react"; +import { PanelLeftClose, Pin, PinOff } from "lucide-react"; import { Logo } from "@/components/custom/logo"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -16,12 +16,13 @@ interface SideProps { /** Switch to a chat id. Pass undefined for the temporary new chat */ onSwitchChat: (chatId: string | undefined) => void; /** Ask the outer page to attempt updating (loading more / refreshing) the chat list. Already deduplicated outside */ - requestChatListUpdateAsync: () => Promise; + requestChatListUpdateAsync: (fromStart?: boolean) => Promise; /** Report the greatest chat index (0-based) currently visible in the list (not counting the temporary chat) */ onChatDisplayRangeChange: (max: number) => void; onSetChatTitle: (chatId: string, title: string) => void; onDeleteChat: (chatId: string) => void; chatList: ServerTypes.GetChatListResult; + pinnedChatIds?: ReadonlySet; activeChatId: string | undefined; onHideSidebar: () => void; } @@ -51,6 +52,7 @@ export function Side({ onSetChatTitle, onDeleteChat, chatList, + pinnedChatIds, activeChatId, onHideSidebar, }: SideProps) { @@ -70,6 +72,8 @@ export function Side({ const [renameInput, setRenameInput] = useState(""); const [renameSubmitting, setRenameSubmitting] = useState(false); const [deleteSubmitting, setDeleteSubmitting] = useState(false); + const [pinSubmitting, setPinSubmitting] = useState(false); + const [pinError, setPinError] = useState(undefined); const [actionError, setActionError] = useState(undefined); const contextMenuRef = useRef(null); @@ -93,11 +97,13 @@ export function Side({ return typeof title === "string" && title.trim().length > 0 ? title : "未命名对话"; }, []); - const onChatContextMenu = useCallback((event: MouseEvent, chat: ServerTypes.GetChatListResult[number]) => { + const onChatContextMenu = useCallback((event: MouseEvent, chat: ServerTypes.GetChatListResult[number]) => { event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + setPinError(undefined); setContextMenu({ - x: event.clientX, - y: event.clientY, + x: event.type === "contextmenu" ? event.clientX : rect.right, + y: event.type === "contextmenu" ? event.clientY : rect.bottom, chatId: chat.id, title: getChatTitle(chat), }); @@ -182,6 +188,24 @@ export function Side({ const closeContextMenu = useCallback(() => setContextMenu(null), []); + const togglePinnedAsync = useCallback(async () => { + if (!contextMenu || pinSubmitting) return; + setPinSubmitting(true); + setPinError(undefined); + try { + await TUIClientSingleton.get().setChatPinnedAsync({ + id: contextMenu.chatId, + pinned: !pinnedChatIds?.has(contextMenu.chatId), + }); + await requestChatListUpdateAsync(true); + closeContextMenu(); + } catch (error) { + setPinError(error instanceof Error ? error.message : String(error)); + } finally { + setPinSubmitting(false); + } + }, [contextMenu, pinSubmitting, pinnedChatIds, requestChatListUpdateAsync, closeContextMenu]); + const openRenameDialog = useCallback((chatId: string, title: string) => { setRenameDialog({ open: true, chatId, title }); setRenameInput(title); @@ -256,7 +280,7 @@ export function Side({ } catch (error) { if (error instanceof RequestError && error.code === ErrorCode.CONFLICT) { closeDeleteDialog(); - await requestChatListUpdateAsync(); + await requestChatListUpdateAsync(true); /** TODO: notify the user with global error banner. */ return; } @@ -311,11 +335,13 @@ export function Side({ id={chat.id} active={chat.id === activeChatId} title={title} + pinned={pinnedChatIds?.has(chat.id)} ref={el => { chatItemRefs.current[idx] = el; }} onChatSelected={onChatSelected} onContextMenu={event => onChatContextMenu(event, chat)} + onMenuClick={event => onChatContextMenu(event, chat)} /> })} {isLoading && ( @@ -344,8 +370,20 @@ export function Side({ style={{ top: contextMenu.y, left: contextMenu.x }} onClick={event => event.stopPropagation()} > + + {pinError &&

{pinError}

} + )} + ; + }, +})); + +const { default: ChatPage } = await import('@/app/chat/page'); + +function displayedIds() { + return within(screen.getByRole('list', { name: 'Chats' })).queryAllByRole('button') + .map(button => button.textContent); +} + +async function renderPage() { + render(); + await screen.findByRole('list', { name: 'Chats' }); +} + +beforeEach(() => { + jest.resetAllMocks(); + regular = Array.from({ length: 110 }, (_, index) => ({ id: `chat-${index}` })); + pinned = []; + client.getChatListAsync.mockImplementation(async ({ start, quantity }) => regular.slice(start, start + quantity)); + client.getPinnedChatListAsync.mockImplementation(async () => [...pinned]); + client.getModelListAsync.mockResolvedValue([]); +}); + +test('shows all pins first in server order and removes duplicate regular rows', async () => { + pinned = [regular[2], regular[100]]; + await renderPage(); + expect(displayedIds().slice(0, 4)).toEqual(['chat-2', 'chat-100', 'chat-0', 'chat-1']); + expect(displayedIds().filter(id => id === 'chat-2')).toHaveLength(1); + expect(sideProps.pinnedChatIds).toEqual(new Set(['chat-2', 'chat-100'])); +}); + +test('uses the unfiltered server count for the next offset', async () => { + pinned = [regular[0], regular[100]]; + await renderPage(); + expect(displayedIds()).toHaveLength(51); + await act(async () => sideProps.requestChatListUpdateAsync()); + expect(client.getChatListAsync).toHaveBeenLastCalledWith({ + start: 50, quantity: 50, metaDataKeys: ['title'], + }); + expect(displayedIds()).toContain('chat-50'); + expect(new Set(displayedIds()).size).toBe(displayedIds().length); +}); + +test('continues past a full page containing only pinned chats', async () => { + pinned = regular.slice(0, 51); + await renderPage(); + expect(client.getChatListAsync.mock.calls.map(([params]) => params.start)).toEqual([0, 50]); + expect(displayedIds()).toContain('chat-51'); +}); + +test('a head refresh replaces both lists in their latest activity order', async () => { + pinned = [regular[2], regular[3]]; + await renderPage(); + const updated = regular[49]; + regular = [updated, ...regular.filter(chat => chat.id !== updated.id)]; + pinned.reverse(); + await act(async () => sideProps.requestChatListUpdateAsync(true)); + expect(displayedIds().slice(0, 4)).toEqual(['chat-3', 'chat-2', 'chat-49', 'chat-0']); + expect(client.getChatListAsync.mock.calls.at(-1)?.[0].start).toBe(0); +}); + +test('unpinning an active chat outside the loaded page does not deselect it', async () => { + pinned = [regular[100]]; + await renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'chat-100' })); + pinned = []; + await act(async () => sideProps.requestChatListUpdateAsync(true)); + expect(screen.getByTestId('active-chat').textContent).toBe('chat-100'); + expect(displayedIds()).not.toContain('chat-100'); + await act(async () => sideProps.requestChatListUpdateAsync()); + await act(async () => sideProps.requestChatListUpdateAsync()); + expect(displayedIds().indexOf('chat-100')).toBe(100); +}); + +test('restarts pagination when the server reports an ordering conflict', async () => { + await renderPage(); + client.getChatListAsync.mockRejectedValueOnce(new RequestError(ErrorCode.CONFLICT, 'List changed')); + regular.reverse(); + await act(async () => sideProps.requestChatListUpdateAsync()); + expect(client.getChatListAsync.mock.calls.map(([params]) => params.start)).toEqual([0, 50, 0]); + expect(displayedIds()[0]).toBe('chat-109'); +}); + +test('queues a head refresh requested during an in-flight page load', async () => { + await renderPage(); + let resolvePage!: (value: ServerTypes.GetChatListResult) => void; + const nextPage = regular.slice(50, 100); + client.getChatListAsync.mockImplementationOnce(() => new Promise(resolve => { resolvePage = resolve; })); + const loading = sideProps.requestChatListUpdateAsync(); + await waitFor(() => expect(client.getChatListAsync).toHaveBeenCalledTimes(2)); + regular.reverse(); + const refreshing = sideProps.requestChatListUpdateAsync(true); + await act(async () => { + resolvePage(nextPage); + await Promise.all([loading, refreshing]); + }); + expect(client.getChatListAsync.mock.calls.map(([params]) => params.start)).toEqual([0, 50, 0]); + expect(displayedIds()[0]).toBe('chat-109'); +}); + +test('a failed refresh does not prevent later refreshes', async () => { + await renderPage(); + client.getPinnedChatListAsync.mockRejectedValueOnce(new Error('Connection lost')); + await act(async () => { + await expect(sideProps.requestChatListUpdateAsync(true)).rejects.toThrow('Connection lost'); + }); + regular.reverse(); + await act(async () => sideProps.requestChatListUpdateAsync(true)); + expect(displayedIds()[0]).toBe('chat-109'); +}); \ No newline at end of file diff --git a/test/auto/app/chat/side.test.tsx b/test/auto/app/chat/side.test.tsx index 816453e..13e74ee 100644 --- a/test/auto/app/chat/side.test.tsx +++ b/test/auto/app/chat/side.test.tsx @@ -1,8 +1,9 @@ /// import React from 'react'; import { jest } from '@jest/globals'; -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen, fireEvent, act, waitFor, within } from '@testing-library/react'; import { Side } from '@/app/chat/side'; +import { TUIClientSingleton } from '@/lib/tui-client-singleton'; function makeChats(count: number) { return Array.from({ length: count }, (_, i) => ({ @@ -204,3 +205,68 @@ describe('Side chat list', () => { jest.useRealTimers(); }); }); + +describe('Chat pin actions', () => { + const setChatPinnedAsync = jest.fn<(params: { id: string; pinned: boolean }) => Promise>(); + + beforeEach(() => { + setChatPinnedAsync.mockReset().mockResolvedValue(undefined); + jest.spyOn(TUIClientSingleton, 'get').mockReturnValue({ + setChatPinnedAsync, + } as unknown as ReturnType); + }); + + afterEach(() => jest.restoreAllMocks()); + + test.each([ + { pinned: false, label: '置顶' }, + { pinned: true, label: '取消置顶' }, + ])('$label saves the new state and refreshes from the head', async ({ pinned, label }) => { + const refresh = jest.fn<() => Promise>().mockResolvedValue(undefined); + renderSide({ + pinnedChatIds: new Set(pinned ? ['chat-1'] : []), + requestChatListUpdateAsync: refresh, + }); + expect(screen.queryAllByLabelText('Pinned chat')).toHaveLength(pinned ? 1 : 0); + fireEvent.contextMenu(screen.getByText('Chat Title 1')); + fireEvent.click(screen.getByRole('button', { name: label })); + await waitFor(() => expect(refresh).toHaveBeenCalledWith(true)); + expect(setChatPinnedAsync).toHaveBeenCalledWith({ id: 'chat-1', pinned: !pinned }); + expect(screen.queryByRole('button', { name: label })).not.toBeInTheDocument(); + }); + + test('the actions button opens the menu without switching chats', () => { + const onSwitchChat = jest.fn(); + renderSide({ onSwitchChat }); + const row = screen.getByText('Chat Title 1').closest('div')!; + fireEvent.click(within(row).getByRole('button', { name: 'Chat actions' })); + expect(screen.getByRole('button', { name: '置顶' })).toBeInTheDocument(); + expect(onSwitchChat).not.toHaveBeenCalled(); + }); + + test('a failed pin stays actionable and reports the error', async () => { + setChatPinnedAsync.mockRejectedValueOnce(new Error('Unable to save pin')); + const refresh = jest.fn<() => Promise>().mockResolvedValue(undefined); + renderSide({ requestChatListUpdateAsync: refresh }); + fireEvent.contextMenu(screen.getByText('Chat Title 0')); + fireEvent.click(screen.getByRole('button', { name: '置顶' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to save pin'); + expect(screen.getByRole('button', { name: '置顶' })).toBeEnabled(); + expect(refresh).not.toHaveBeenCalled(); + expect(screen.queryByLabelText('Pinned chat')).not.toBeInTheDocument(); + }); + + test('disables the action while the pin is being saved', async () => { + let resolvePin!: () => void; + setChatPinnedAsync.mockImplementationOnce(() => new Promise(resolve => { resolvePin = resolve; })); + renderSide(); + fireEvent.contextMenu(screen.getByText('Chat Title 0')); + const button = screen.getByRole('button', { name: '置顶' }); + fireEvent.click(button); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(setChatPinnedAsync).toHaveBeenCalledTimes(1); + await act(async () => resolvePin()); + expect(screen.queryByRole('button', { name: '置顶' })).not.toBeInTheDocument(); + }); +}); diff --git a/test/auto/sdk/app/test-tui-client.ts b/test/auto/sdk/app/test-tui-client.ts new file mode 100644 index 0000000..9b550a8 --- /dev/null +++ b/test/auto/sdk/app/test-tui-client.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, jest, test } from '@jest/globals'; +import * as rpc from '../../../../src/sdk/app/rpc'; +import { ErrorCode } from '../../../../src/sdk/types/Rpc'; + +const makeRequestAsync = jest.fn<(method: string, params: unknown) => Promise>(); + +jest.unstable_mockModule('../../../../src/sdk/app/rpc', () => ({ + ...rpc, + Client: jest.fn(() => ({ + makeRequestAsync, + connectAsync: async () => {}, + close: () => {}, + })), +})); +jest.unstable_mockModule('../../../../src/sdk/session/secure-session', () => ({ + Connection: jest.fn(), +})); +jest.unstable_mockModule('../../../../src/sdk/session/websocket-client', () => ({ + Connection: jest.fn(), +})); + +const { TUIClient } = await import('../../../../src/sdk/tui-client'); + +let client: InstanceType; + +beforeEach(async () => { + makeRequestAsync.mockReset(); + client = new TUIClient('localhost', () => {}, () => {}); + await client.connectAsync('test-user', 'test-password'); +}); + +describe('Pinned chat client', () => { + test('fetches an unpaged list without changing the regular list', async () => { + const pinned = [{ id: 'pinned', metadata: { title: 'Pinned' } }]; + const regular = [{ id: 'recent' }, ...pinned]; + makeRequestAsync.mockResolvedValueOnce(pinned).mockResolvedValueOnce(regular); + + await expect(client.getPinnedChatListAsync({ metaDataKeys: ['title'] })).resolves.toEqual(pinned); + expect(makeRequestAsync).toHaveBeenNthCalledWith(1, 'getPinnedChatList', { metaDataKeys: ['title'] }); + await expect(client.getChatListAsync({ start: 0, quantity: 50 })).resolves.toEqual(regular); + expect(makeRequestAsync).toHaveBeenNthCalledWith(2, 'getChatList', { + start: 0, quantity: 50, metaDataKeys: undefined, + }); + }); + + test('reuses the pinned list on NOT_MODIFIED and accepts a changed order', async () => { + const pinned = [{ id: 'first' }, { id: 'second' }]; + makeRequestAsync.mockResolvedValueOnce(pinned) + .mockRejectedValueOnce(new rpc.RequestError(ErrorCode.NOT_MODIFIED, 'Unchanged')) + .mockResolvedValueOnce([...pinned].reverse()); + + await expect(client.getPinnedChatListAsync({})).resolves.toEqual(pinned); + await expect(client.getPinnedChatListAsync({})).resolves.toEqual(pinned); + await expect(client.getPinnedChatListAsync({})).resolves.toEqual([...pinned].reverse()); + }); + + test.each([true, false])('sets pin state explicitly to %s', async pinned => { + makeRequestAsync.mockResolvedValue(undefined); + await client.setChatPinnedAsync({ id: 'chat', pinned }); + expect(makeRequestAsync).toHaveBeenCalledWith('setChatPinned', { id: 'chat', pinned }); + }); + + test.each([ + { result: null }, + { result: {} }, + { result: [null] }, + { result: [{ id: 1 }] }, + { result: [{ id: 'chat', metadata: null }] }, + ])('rejects invalid pinned lists: $result', async ({ result }) => { + makeRequestAsync.mockResolvedValue(result); + await expect(client.getPinnedChatListAsync({})).rejects.toThrow(rpc.RequestError); + }); + + test('requires a connection for both pin endpoints', async () => { + const disconnected = new TUIClient('localhost', () => {}, () => {}); + await expect(disconnected.getPinnedChatListAsync({})).rejects.toThrow('client not connected'); + await expect(disconnected.setChatPinnedAsync({ id: 'chat', pinned: true })) + .rejects.toThrow('client not connected'); + }); +}); \ No newline at end of file