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:
iven
2026-03-14 23:16:32 +08:00
parent 67e1da635d
commit 07079293f4
126 changed files with 36229 additions and 1035 deletions

View 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();
});
});