Compare commits

...

2 Commits

Author SHA1 Message Date
AmAzing129 b1367338b2 🔀 chore: merge canary into fix/task-agent-sidebar-context 2026-05-18 14:33:18 +08:00
AmAzing- 7453878589 🐛 fix(task): sync agent context on task detail 2026-05-17 19:15:15 +08:00
5 changed files with 154 additions and 4 deletions
@@ -0,0 +1,58 @@
/**
* @vitest-environment happy-dom
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAgentStore } from '@/store/agent';
import { initialState as initialAgentState } from '@/store/agent/initialState';
import { useChatStore } from '@/store/chat';
import { initialState as initialChatState } from '@/store/chat/initialState';
import { syncTaskAgentContext } from './TaskAgentContextSync';
vi.hoisted(() => {
const storage = {
clear: vi.fn(),
getItem: vi.fn(() => null),
removeItem: vi.fn(),
setItem: vi.fn(),
};
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
});
describe('syncTaskAgentContext', () => {
beforeEach(() => {
useAgentStore.setState(initialAgentState, false);
useChatStore.setState(
{
...initialChatState,
activeAgentId: 'agent-a',
activeGroupId: 'group-a',
activeThreadId: 'thread-a',
activeTopicId: 'topic-a',
},
false,
);
});
it('syncs task assignee agent into agent and chat contexts', () => {
syncTaskAgentContext('agent-b');
expect(useAgentStore.getState().activeAgentId).toBe('agent-b');
expect(useChatStore.getState().activeAgentId).toBe('agent-b');
expect(useChatStore.getState().activeGroupId).toBeUndefined();
expect(useChatStore.getState().activeThreadId).toBeUndefined();
expect(useChatStore.getState().activeTopicId).toBeUndefined();
});
it('clears stale agent context for an unassigned task', () => {
syncTaskAgentContext(null);
expect(useAgentStore.getState().activeAgentId).toBeUndefined();
expect(useChatStore.getState().activeAgentId).toBeUndefined();
});
});
@@ -0,0 +1,44 @@
'use client';
import { memo, useEffect } from 'react';
import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors';
export const syncTaskAgentContext = (agentId?: string | null) => {
const nextAgentId = agentId ?? undefined;
useAgentStore.getState().setActiveAgentId(nextAgentId);
useChatStore.setState(
{
activeAgentId: nextAgentId,
activeGroupId: undefined,
activeThreadId: undefined,
activeTopicId: undefined,
},
false,
'TaskAgentContextSync/syncAgentId',
);
};
const TaskAgentContextSync = memo(() => {
const isLogin = useUserStore(authSelectors.isLogin);
const agentId = useTaskStore(taskDetailSelectors.activeTaskAgentId);
const useFetchAgentConfig = useAgentStore((s) => s.useFetchAgentConfig);
useEffect(() => {
syncTaskAgentContext(agentId);
}, [agentId]);
useFetchAgentConfig(isLogin, agentId ?? '');
return null;
});
TaskAgentContextSync.displayName = 'TaskAgentContextSync';
export default TaskAgentContextSync;
@@ -14,6 +14,7 @@ import { taskDetailSelectors } from '@/store/task/selectors';
import Breadcrumb from '../shared/Breadcrumb';
import TaskActivities from './TaskActivities';
import TaskAgentContextSync from './TaskAgentContextSync';
import TaskArtifacts from './TaskArtifacts';
import TaskDetailAssignee from './TaskDetailAssignee';
import TaskDetailHeaderActions from './TaskDetailHeaderActions';
@@ -49,6 +50,7 @@ const TaskDetailPage = memo<TaskDetailPageProps>(({ taskId }) => {
return (
<Flexbox flex={1} height={'100%'} style={{ minHeight: 0 }}>
<TaskAgentContextSync />
<NavHeader
left={
<>
@@ -14,6 +14,10 @@ const switchTopicMock = vi.hoisted(() => vi.fn());
const toggleCommandMenuMock = vi.hoisted(() => vi.fn());
const useParamsMock = vi.hoisted(() => vi.fn());
const usePathnameMock = vi.hoisted(() => vi.fn());
const agentStoreMock = vi.hoisted(() => ({
activeAgentId: undefined as string | undefined,
heterogeneousProviderType: undefined as string | undefined,
}));
vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
@@ -84,12 +88,13 @@ vi.mock('@/libs/swr', () => ({
}));
vi.mock('@/store/agent', () => ({
useAgentStore: (selector: (state: unknown) => unknown) => selector({}),
useAgentStore: (selector: (state: typeof agentStoreMock) => unknown) => selector(agentStoreMock),
}));
vi.mock('@/store/agent/selectors', () => ({
agentSelectors: {
currentAgentHeterogeneousProviderType: () => undefined,
currentAgentHeterogeneousProviderType: (state: typeof agentStoreMock) =>
state.heterogeneousProviderType,
},
}));
@@ -128,6 +133,8 @@ describe('Agent sidebar header nav', () => {
toggleCommandMenuMock.mockReset();
useParamsMock.mockReset();
usePathnameMock.mockReset();
agentStoreMock.activeAgentId = undefined;
agentStoreMock.heterogeneousProviderType = undefined;
useParamsMock.mockReturnValue({ aid: 'agt_eH4zL98zBx5u', topicId: 'tpc_2FCHvjS7d4CA' });
});
@@ -155,4 +162,42 @@ describe('Agent sidebar header nav', () => {
expect(pushMock).toHaveBeenCalledWith('/agent/agt_eH4zL98zBx5u');
expect(mutateMock).toHaveBeenCalledTimes(1);
});
it('falls back to the active agent when retained on a task route without an agent param', () => {
agentStoreMock.activeAgentId = 'agt_from_task';
useParamsMock.mockReturnValue({});
usePathnameMock.mockReturnValue('/task/T-1');
render(<Nav />);
fireEvent.click(screen.getByRole('button', { name: 'actions.addNewTopic' }));
fireEvent.click(screen.getByRole('button', { name: 'tab.profile' }));
fireEvent.click(screen.getByRole('button', { name: 'tab.integration' }));
expect(pushMock).toHaveBeenNthCalledWith(1, '/agent/agt_from_task');
expect(pushMock).toHaveBeenNthCalledWith(2, '/agent/agt_from_task/profile');
expect(pushMock).toHaveBeenNthCalledWith(3, '/agent/agt_from_task/channel');
expect(mutateMock).toHaveBeenCalledTimes(1);
expect(switchTopicMock).toHaveBeenCalledTimes(2);
expect(switchTopicMock).toHaveBeenNthCalledWith(1, null, { skipRefreshMessage: true });
expect(switchTopicMock).toHaveBeenNthCalledWith(2, null, { skipRefreshMessage: true });
});
it('keeps channel visible for Claude Code heterogeneous agents', () => {
agentStoreMock.heterogeneousProviderType = 'claude-code';
usePathnameMock.mockReturnValue('/agent/agt_eH4zL98zBx5u');
render(<Nav />);
expect(screen.getByRole('button', { name: 'tab.integration' })).toBeInTheDocument();
});
it('hides channel for non-Claude Code heterogeneous agents', () => {
agentStoreMock.heterogeneousProviderType = 'codex';
usePathnameMock.mockReturnValue('/agent/agt_eH4zL98zBx5u');
render(<Nav />);
expect(screen.queryByRole('button', { name: 'tab.integration' })).not.toBeInTheDocument();
});
});
@@ -22,7 +22,8 @@ const Nav = memo(() => {
const { t } = useTranslation('chat');
const { t: tTopic } = useTranslation('topic');
const params = useParams();
const agentId = params.aid;
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const agentId = params.aid ?? activeAgentId;
const pathname = usePathname();
const isProfileActive = pathname.includes('/profile');
const isChannelActive = pathname.includes('/channel');
@@ -32,7 +33,7 @@ const Nav = memo(() => {
const heterogeneousProviderType = useAgentStore(
agentSelectors.currentAgentHeterogeneousProviderType,
);
const hideProfile = !isAgentEditable;
const hideProfile = !isAgentEditable || !agentId;
// Claude Code agents can use message channels; other hetero providers (e.g. codex) still hide it.
const hideChannel =
hideProfile || (!!heterogeneousProviderType && heterogeneousProviderType !== 'claude-code');