Skip to content
72 changes: 41 additions & 31 deletions src/hooks/useIntegrationManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
} | null>(null);
const [callBackUrl, setCallBackUrl] = useState<string | null>(null);

const itemsRef = useRef(items);
const configsRef = useRef(configs);
const emailRef = useRef(email);

useEffect(() => {
itemsRef.current = items;
configsRef.current = configs;
emailRef.current = email;
});

// Fetch installed configs
const fetchInstalled = useCallback(async (ignore: boolean = false) => {
try {
Expand All @@ -74,11 +84,11 @@ export function useIntegrationManagement(items: IntegrationItem[]) {

// Recalculate installed status when items or configs change
useEffect(() => {
const currentItems = itemsRef.current;
const map: { [key: string]: boolean } = {};

items.forEach((item) => {
currentItems.forEach((item) => {
if (item.key === 'Google Calendar') {
// Only mark installed when refresh token is present (auth completed)
const hasRefreshToken = configs.some(
(c: any) =>
c.config_group?.toLowerCase() === 'google calendar' &&
Expand All @@ -88,7 +98,6 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
);
map[item.key] = hasRefreshToken;
} else if (item.key === 'LinkedIn') {
// LinkedIn: check if access token is present
const hasAccessToken = configs.some(
(c: any) =>
c.config_group?.toLowerCase() === 'linkedin' &&
Expand All @@ -98,7 +107,6 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
);
map[item.key] = hasAccessToken;
} else {
// For other integrations, use config_group presence
const hasConfig = configs.some(
(c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
);
Expand All @@ -107,26 +115,25 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
});

setInstalled(map);
}, [items, configs]);
}, [configs]);

// Save environment variable and config
const saveEnvAndConfig = useCallback(
async (provider: string, envVarKey: string, value: string) => {
const configPayload = {
// Keep exact group name to satisfy backend whitelist
config_group: provider,
config_name: envVarKey,
config_value: value,
};

// Fetch latest configs to avoid stale state when deciding POST/PUT
let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
let latestConfigs: any[] = Array.isArray(configsRef.current)
? configsRef.current
: [];
try {
const fresh = await proxyFetchGet('/api/v1/configs');
if (Array.isArray(fresh)) latestConfigs = fresh;
} catch {}

// Backend uniqueness is by config_name for a user
let existingConfig = latestConfigs.find(
(c: any) => c.config_name === envVarKey
);
Expand Down Expand Up @@ -156,23 +163,26 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
}

if (window.electronAPI?.envWrite) {
await window.electronAPI.envWrite(email, { key: envVarKey, value });
await window.electronAPI.envWrite(emailRef.current, {
key: envVarKey,
value,
});
}
},
[configs, email]
[]
);

// Process OAuth callback
const processOauth = useCallback(
async (data: { provider: string; code: string }) => {
const currentItems = itemsRef.current;
if (isLockedRef.current) return;
if (!items || items.length === 0) {
// Items not ready, cache event, wait for items to have value
if (!currentItems || currentItems.length === 0) {
pendingOauthEventRef.current = data;
return;
}
const provider = data.provider.toLowerCase();
const hasProviderInItems = items.some(
const hasProviderInItems = currentItems.some(
(item) => item.key.toLowerCase() === provider
);
if (!hasProviderInItems) {
Expand All @@ -184,7 +194,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
`/api/v1/oauth/${provider}/token`,
{ code: data.code }
);
const currentItem = items.find(
const currentItem = currentItems.find(
(item) => item.key.toLowerCase() === provider
);
if (provider === 'slack') {
Expand All @@ -210,17 +220,14 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
);
}
} else if (provider === 'linkedin') {
// LinkedIn OAuth: save token via local backend endpoint and config
if (tokenResult.access_token) {
try {
// Save token to local backend toolkit (token file is stored locally)
await fetchPost('/linkedin/save-token', {
access_token: tokenResult.access_token,
refresh_token: tokenResult.refresh_token,
expires_in: tokenResult.expires_in,
});

// Also save to config for UI status tracking
await saveEnvAndConfig(
'LinkedIn',
'LINKEDIN_ACCESS_TOKEN',
Expand Down Expand Up @@ -253,7 +260,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
isLockedRef.current = false;
}
},
[items, saveEnvAndConfig, fetchInstalled]
[saveEnvAndConfig, fetchInstalled]
);

// Listen to main process OAuth authorization callback
Expand Down Expand Up @@ -283,44 +290,48 @@ export function useIntegrationManagement(items: IntegrationItem[]) {

// Process cached OAuth event when items are ready
useEffect(() => {
if (items && items.length > 0 && pendingOauthEventRef.current) {
const currentItems = itemsRef.current;
if (
currentItems &&
currentItems.length > 0 &&
pendingOauthEventRef.current
) {
const pending = pendingOauthEventRef.current;
const provider = pending.provider.toLowerCase();
const hasProviderInItems = items.some(
const hasProviderInItems = currentItems.some(
(item) => item.key.toLowerCase() === provider
);
if (hasProviderInItems) {
processOauth(pending);
pendingOauthEventRef.current = null;
}
}
}, [items, processOauth]);
}, [processOauth]);

// Uninstall integration
const handleUninstall = useCallback(
async (item: IntegrationItem) => {
checkAgentTool(item.key);
const groupKey = item.key.toLowerCase();
const toDelete = configs.filter(
const toDelete = configsRef.current.filter(
(c: any) => c.config_group && c.config_group.toLowerCase() === groupKey
);
for (const config of toDelete) {
try {
await proxyFetchDelete(`/api/v1/configs/${config.id}`);
// Delete env
if (
item.env_vars &&
item.env_vars.length > 0 &&
window.electronAPI?.envRemove
) {
await window.electronAPI.envRemove(email, item.env_vars[0]);
await window.electronAPI.envRemove(
emailRef.current,
item.env_vars[0]
);
}
} catch (_e) {
// Ignore error
}
} catch (_e) {}
}

// Clean up authentication tokens for Google Calendar, Notion, and LinkedIn
if (item.key === 'Google Calendar') {
try {
await fetchDelete('/uninstall/tool/google_calendar');
Expand All @@ -344,12 +355,11 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
}
}

// Update configs after deletion
setConfigs((prev) =>
prev.filter((c: any) => c.config_group?.toLowerCase() !== groupKey)
);
},
[configs, email, checkAgentTool]
[checkAgentTool]
);

// Helper to create MCP object from integration item
Expand Down
8 changes: 6 additions & 2 deletions src/store/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
const res = await proxyFetchGet(`/api/v1/chat/snapshots`, {
api_task_id: taskId,
});
if (res) {
if (res && Array.isArray(res)) {
snapshots = [
...new Map(
res.map((item: any) => [item.camel_task_id, item])
Expand Down Expand Up @@ -1034,14 +1034,18 @@ const chatStore = (initial?: Partial<ChatStore>) =>
return;
}

const isQueueEvent = agentMessages.step === AgentStep.REMOVE_TASK;

if (
currentTask.status === ChatTaskStatus.FINISHED &&
!isTaskSwitchingEvent &&
!isMultiTurnSimpleAnswer
!isMultiTurnSimpleAnswer &&
!isQueueEvent
) {
// Ignore messages for finished tasks except:
// 1. Task switching events (create new chatStore)
// 2. Simple answer events (direct response without new chatStore)
// 3. Queue events (remove_task affects the project queue, not the task)
console.log(
`Ignoring SSE message for finished task ${lockedTaskId}, step: ${agentMessages.step}`
);
Expand Down
5 changes: 4 additions & 1 deletion test/integration/chatStore/newProject.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ describe('Integration Test: Case 1 - New Project', () => {
});

it('should create historyId after starting task', async () => {
const { result } = renderHook(() => useProjectStore());
const { result, rerender } = renderHook(() => useProjectStore());

await act(async () => {
const projectId = result.current.createProject('Test Project');
Expand All @@ -190,12 +190,15 @@ describe('Integration Test: Case 1 - New Project', () => {

// Step 4: Start task
await chatStore.getState().startTask(taskId);
rerender();

// Wait for historyId to be set
await waitFor(
() => {
rerender();
const historyId = result.current.getHistoryId(projectId);
expect(historyId).toBeDefined();
expect(typeof historyId).toBe('string');
expect(historyId).toMatch(/^history-/);
},
{ timeout: 2000 }
Expand Down
14 changes: 7 additions & 7 deletions test/integration/chatStore/replayComplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ describe('Integration Test: Replay Functionality', () => {
mockFetchEventSource.mockImplementation(
async (url: string, options: any) => {
console.log('SSE URL called:', url);
if (url.includes('/api/chat/steps/playback/') && options.onmessage) {
if (url.includes('/chat/steps/playback/') && options.onmessage) {
await replayEventSequence(options.onmessage);
}
}
Expand Down Expand Up @@ -252,7 +252,7 @@ describe('Integration Test: Replay Functionality', () => {

mockFetchEventSource.mockImplementation(
async (url: string, options: any) => {
if (url.includes('/api/chat/steps/playback/') && options.onmessage) {
if (url.includes('/chat/steps/playback/') && options.onmessage) {
await replayEventSequence(options.onmessage);
}
}
Expand Down Expand Up @@ -334,7 +334,7 @@ describe('Integration Test: Replay Functionality', () => {

mockFetchEventSource.mockImplementation(
async (url: string, options: any) => {
if (url.includes('/api/chat/steps/playback/') && options.onmessage) {
if (url.includes('/chat/steps/playback/') && options.onmessage) {
await replayEventSequence(options.onmessage);
}
}
Expand Down Expand Up @@ -389,7 +389,7 @@ describe('Integration Test: Replay Functionality', () => {
// Update mock for post-replay events
mockFetchEventSource.mockImplementation(
async (url: string, options: any) => {
if (!url.includes('/api/chat/steps/playback/') && options.onmessage) {
if (!url.includes('/chat/steps/playback/') && options.onmessage) {
await postReplayEventSequence(options.onmessage);
}
}
Expand Down Expand Up @@ -499,7 +499,7 @@ describe('Integration Test: Replay Functionality', () => {
mockFetchEventSource.mockImplementation(
async (url: string, options: any) => {
console.log('Mock SSE called with URL:', url);
if (url.includes('/api/chat/steps/playback/') && options.onmessage) {
if (url.includes('/chat/steps/playback/') && options.onmessage) {
// This is replay SSE
console.log('Processing replay events');
await replayEventSequence(options.onmessage);
Expand Down Expand Up @@ -659,7 +659,7 @@ describe('Issue #619 - Duplicate Task Boxes after replay', () => {

if (options.onmessage) {
// First simulate replay of previous event to establish context
if (sseCallCount === 1 && url.includes('/api/chat/steps/playback/')) {
if (sseCallCount === 1 && url.includes('/chat/steps/playback/')) {
console.log(
'Simulating replay mechanism for previous calendar task'
);
Expand Down Expand Up @@ -875,7 +875,7 @@ describe('Issue #619 - Duplicate Task Boxes after replay', () => {
// First call: initial task
console.log('Processing initial task events');
await initialSequence(options.onmessage);
} else if (url.includes('/api/chat/steps/playback/')) {
} else if (url.includes('/chat/steps/playback/')) {
// Subsequent calls: replay
console.log('Processing replay events');
await replaySequence(options.onmessage);
Expand Down
Loading