feat(hands): restructure Hands UI with Chinese localization
Major changes: - Add HandList.tsx component for left sidebar - Add HandTaskPanel.tsx for middle content area - Restructure Sidebar tabs: 分身/HANDS/Workflow - Remove Hands tab from RightPanel - Localize all UI text to Chinese - Archive legacy OpenClaw documentation - Add Hands integration lessons document - Update feature checklist with new components UI improvements: - Left sidebar now shows Hands list with status icons - Middle area shows selected Hand's tasks and results - Consistent styling with Tailwind CSS - Chinese status labels and buttons Documentation: - Create docs/archive/openclaw-legacy/ for old docs - Add docs/knowledge-base/hands-integration-lessons.md - Update docs/knowledge-base/feature-checklist.md - Update docs/knowledge-base/README.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
500
tests/desktop/chatStore.test.ts
Normal file
500
tests/desktop/chatStore.test.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const chatMock = vi.fn();
|
||||
const onAgentStreamMock = vi.fn((_callback?: (delta: any) => void) => () => {});
|
||||
let agentStreamHandler: ((delta: any) => void) | null = null;
|
||||
|
||||
vi.mock('../../desktop/src/lib/gateway-client', () => ({
|
||||
getGatewayClient: () => ({
|
||||
chat: chatMock,
|
||||
onAgentStream: (callback: (delta: any) => void) => {
|
||||
agentStreamHandler = callback;
|
||||
return onAgentStreamMock();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Re-export AgentStreamDelta type for test usage
|
||||
type AgentStreamDelta = {
|
||||
stream: 'assistant' | 'tool' | 'lifecycle' | 'hand' | 'workflow';
|
||||
delta?: string;
|
||||
content?: string;
|
||||
tool?: string;
|
||||
toolInput?: string;
|
||||
toolOutput?: string;
|
||||
phase?: 'start' | 'end' | 'error';
|
||||
runId?: string;
|
||||
error?: string;
|
||||
handName?: string;
|
||||
handStatus?: string;
|
||||
handResult?: unknown;
|
||||
workflowId?: string;
|
||||
workflowStep?: string;
|
||||
workflowStatus?: string;
|
||||
workflowResult?: unknown;
|
||||
};
|
||||
|
||||
describe('chatStore sendMessage', () => {
|
||||
beforeEach(() => {
|
||||
chatMock.mockReset();
|
||||
onAgentStreamMock.mockClear();
|
||||
agentStreamHandler = null;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('sends the first message with a generated sessionKey and persists it after success', async () => {
|
||||
chatMock.mockResolvedValue({ runId: 'run_1' });
|
||||
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
});
|
||||
|
||||
await useChatStore.getState().sendMessage('hello');
|
||||
|
||||
expect(chatMock).toHaveBeenCalledTimes(1);
|
||||
const [, options] = chatMock.mock.calls[0];
|
||||
expect(options).toMatchObject({
|
||||
sessionKey: expect.stringMatching(/^session_/),
|
||||
});
|
||||
expect(options.agentId).toBeUndefined();
|
||||
expect(useChatStore.getState().sessionKey).toBe(options.sessionKey);
|
||||
});
|
||||
|
||||
it('does not persist a generated sessionKey when the request fails', async () => {
|
||||
chatMock.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
});
|
||||
|
||||
await useChatStore.getState().sendMessage('hello');
|
||||
|
||||
expect(chatMock).toHaveBeenCalledTimes(1);
|
||||
expect(chatMock.mock.calls[0][1].sessionKey).toMatch(/^session_/);
|
||||
expect(useChatStore.getState().sessionKey).toBeNull();
|
||||
});
|
||||
|
||||
it('does not send local clone ids directly as gateway agent ids', async () => {
|
||||
chatMock.mockResolvedValue({ runId: 'run_clone' });
|
||||
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: {
|
||||
id: 'clone_alpha',
|
||||
name: 'Alpha',
|
||||
icon: 'A',
|
||||
color: 'bg-orange-500',
|
||||
lastMessage: '',
|
||||
time: '',
|
||||
},
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
});
|
||||
|
||||
await useChatStore.getState().sendMessage('hello');
|
||||
|
||||
expect(chatMock).toHaveBeenCalledTimes(1);
|
||||
expect(chatMock.mock.calls[0][1]).toMatchObject({
|
||||
sessionKey: expect.stringMatching(/^session_/),
|
||||
});
|
||||
expect(chatMock.mock.calls[0][1].agentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not send the placeholder default agent id to the gateway', async () => {
|
||||
chatMock.mockResolvedValue({ runId: 'run_default' });
|
||||
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: {
|
||||
id: '1',
|
||||
name: 'ZCLAW',
|
||||
icon: '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: '发送消息开始对话',
|
||||
time: '',
|
||||
},
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
});
|
||||
|
||||
await useChatStore.getState().sendMessage('hello');
|
||||
|
||||
expect(chatMock).toHaveBeenCalledTimes(1);
|
||||
expect(chatMock.mock.calls[0][1].agentId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatStore agent/session isolation', () => {
|
||||
beforeEach(() => {
|
||||
chatMock.mockReset();
|
||||
onAgentStreamMock.mockClear();
|
||||
agentStreamHandler = null;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('saves the active conversation and clears session state when switching to a different agent', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user_1',
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: {
|
||||
id: 'clone_alpha',
|
||||
name: 'Alpha',
|
||||
icon: 'A',
|
||||
color: 'bg-orange-500',
|
||||
lastMessage: '',
|
||||
time: '',
|
||||
},
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_alpha',
|
||||
});
|
||||
|
||||
useChatStore.getState().setCurrentAgent({
|
||||
id: 'clone_beta',
|
||||
name: 'Beta',
|
||||
icon: 'B',
|
||||
color: 'bg-blue-500',
|
||||
lastMessage: '',
|
||||
time: '',
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
expect(state.currentAgent?.id).toBe('clone_beta');
|
||||
expect(state.messages).toEqual([]);
|
||||
expect(state.sessionKey).toBeNull();
|
||||
expect(state.currentConversationId).toBeNull();
|
||||
expect(state.conversations).toHaveLength(1);
|
||||
expect(state.conversations[0]).toMatchObject({
|
||||
sessionKey: 'session_alpha',
|
||||
agentId: 'clone_alpha',
|
||||
title: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the conversation agent when switching back to a saved conversation', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
conversations: [
|
||||
{
|
||||
id: 'conv_saved',
|
||||
title: 'saved',
|
||||
messages: [
|
||||
{
|
||||
id: 'user_saved',
|
||||
role: 'user',
|
||||
content: 'saved',
|
||||
timestamp: new Date('2026-03-13T00:01:00.000Z'),
|
||||
},
|
||||
],
|
||||
sessionKey: 'session_saved',
|
||||
agentId: 'clone_alpha',
|
||||
createdAt: new Date('2026-03-13T00:01:00.000Z'),
|
||||
updatedAt: new Date('2026-03-13T00:01:00.000Z'),
|
||||
},
|
||||
],
|
||||
currentConversationId: null,
|
||||
agents: [
|
||||
{
|
||||
id: 'clone_alpha',
|
||||
name: 'Alpha',
|
||||
icon: 'A',
|
||||
color: 'bg-orange-500',
|
||||
lastMessage: '',
|
||||
time: '',
|
||||
},
|
||||
],
|
||||
currentAgent: {
|
||||
id: 'clone_beta',
|
||||
name: 'Beta',
|
||||
icon: 'B',
|
||||
color: 'bg-blue-500',
|
||||
lastMessage: '',
|
||||
time: '',
|
||||
},
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
});
|
||||
|
||||
useChatStore.getState().switchConversation('conv_saved');
|
||||
|
||||
const state = useChatStore.getState();
|
||||
expect(state.currentConversationId).toBe('conv_saved');
|
||||
expect(state.sessionKey).toBe('session_saved');
|
||||
expect(state.currentAgent?.id).toBe('clone_alpha');
|
||||
expect(state.messages).toHaveLength(1);
|
||||
expect(state.messages[0].content).toBe('saved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatStore stream correlation', () => {
|
||||
beforeEach(() => {
|
||||
chatMock.mockReset();
|
||||
onAgentStreamMock.mockClear();
|
||||
agentStreamHandler = null;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('correlates assistant deltas to the matching runId instead of the last streaming message', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant_old',
|
||||
role: 'assistant',
|
||||
content: 'old:',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_old',
|
||||
},
|
||||
{
|
||||
id: 'assistant_new',
|
||||
role: 'assistant',
|
||||
content: 'new:',
|
||||
timestamp: new Date('2026-03-13T00:00:01.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_new',
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: true,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_test',
|
||||
});
|
||||
|
||||
const unsubscribe = useChatStore.getState().initStreamListener();
|
||||
expect(agentStreamHandler).toBeTypeOf('function');
|
||||
|
||||
agentStreamHandler?.({
|
||||
stream: 'assistant',
|
||||
runId: 'run_old',
|
||||
delta: 'delta-for-old',
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
expect(state.messages.find((message) => message.id === 'assistant_old')?.content).toBe('old:delta-for-old');
|
||||
expect(state.messages.find((message) => message.id === 'assistant_new')?.content).toBe('new:');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('marks only the matching runId message as completed on lifecycle end', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant_old',
|
||||
role: 'assistant',
|
||||
content: 'old',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_old',
|
||||
},
|
||||
{
|
||||
id: 'assistant_new',
|
||||
role: 'assistant',
|
||||
content: 'new',
|
||||
timestamp: new Date('2026-03-13T00:00:01.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_new',
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: true,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_test',
|
||||
});
|
||||
|
||||
const unsubscribe = useChatStore.getState().initStreamListener();
|
||||
|
||||
agentStreamHandler?.({
|
||||
stream: 'lifecycle',
|
||||
runId: 'run_old',
|
||||
phase: 'end',
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
expect(state.messages.find((message) => message.id === 'assistant_old')?.streaming).toBe(false);
|
||||
expect(state.messages.find((message) => message.id === 'assistant_new')?.streaming).toBe(true);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatStore OpenFang events', () => {
|
||||
beforeEach(() => {
|
||||
chatMock.mockReset();
|
||||
onAgentStreamMock.mockClear();
|
||||
agentStreamHandler = null;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('creates a hand message when receiving hand stream events', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant_1',
|
||||
role: 'assistant',
|
||||
content: 'Processing...',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_1',
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: true,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_test',
|
||||
});
|
||||
|
||||
const unsubscribe = useChatStore.getState().initStreamListener();
|
||||
|
||||
agentStreamHandler?.({
|
||||
stream: 'hand',
|
||||
runId: 'run_1',
|
||||
handName: 'summarize',
|
||||
handStatus: 'completed',
|
||||
handResult: { summary: 'This is a test summary' },
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
const handMsg = state.messages.find((m) => m.role === 'hand');
|
||||
expect(handMsg).toBeDefined();
|
||||
expect(handMsg?.handName).toBe('summarize');
|
||||
expect(handMsg?.handStatus).toBe('completed');
|
||||
expect(handMsg?.content).toContain('summary');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('creates a workflow message when receiving workflow stream events', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant_1',
|
||||
role: 'assistant',
|
||||
content: 'Running workflow...',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_1',
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: true,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_test',
|
||||
});
|
||||
|
||||
const unsubscribe = useChatStore.getState().initStreamListener();
|
||||
|
||||
agentStreamHandler?.({
|
||||
stream: 'workflow',
|
||||
runId: 'run_1',
|
||||
workflowId: 'wf_analysis',
|
||||
workflowStep: 'step_2',
|
||||
workflowStatus: 'running',
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
const workflowMsg = state.messages.find((m) => m.role === 'workflow');
|
||||
expect(workflowMsg).toBeDefined();
|
||||
expect(workflowMsg?.workflowId).toBe('wf_analysis');
|
||||
expect(workflowMsg?.workflowStep).toBe('step_2');
|
||||
expect(workflowMsg?.workflowStatus).toBe('running');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('includes workflow result in the message content when available', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant_1',
|
||||
role: 'assistant',
|
||||
content: 'Running workflow...',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_1',
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: true,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_test',
|
||||
});
|
||||
|
||||
const unsubscribe = useChatStore.getState().initStreamListener();
|
||||
|
||||
agentStreamHandler?.({
|
||||
stream: 'workflow',
|
||||
runId: 'run_1',
|
||||
workflowId: 'wf_analysis',
|
||||
workflowStep: 'final',
|
||||
workflowStatus: 'completed',
|
||||
workflowResult: { output: 'Analysis complete', score: 95 },
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
const workflowMsg = state.messages.find((m) => m.role === 'workflow');
|
||||
expect(workflowMsg).toBeDefined();
|
||||
expect(workflowMsg?.content).toContain('output');
|
||||
expect(workflowMsg?.content).toContain('score');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user