Optimize terminal side panel performance, keep process status labels visible, and improve heavy panel loading behavior.
1112 lines
40 KiB
TypeScript
1112 lines
40 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
|
import {
|
|
STORAGE_KEY_AI_PROVIDERS,
|
|
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
|
STORAGE_KEY_AI_ACTIVE_MODEL,
|
|
STORAGE_KEY_AI_PERMISSION_MODE,
|
|
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
|
|
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
|
STORAGE_KEY_AI_EXTERNAL_AGENTS,
|
|
STORAGE_KEY_AI_DEFAULT_AGENT,
|
|
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
|
|
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
|
STORAGE_KEY_AI_MAX_ITERATIONS,
|
|
STORAGE_KEY_AI_SESSIONS,
|
|
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
|
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
|
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
|
STORAGE_KEY_AI_WEB_SEARCH,
|
|
STORAGE_KEY_AI_QUICK_MESSAGES,
|
|
} from '../../infrastructure/config/storageKeys';
|
|
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
|
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
|
import type {
|
|
AIDraft,
|
|
AISession,
|
|
AIPermissionMode,
|
|
AIToolIntegrationMode,
|
|
ProviderConfig,
|
|
HostAIPermission,
|
|
ExternalAgentConfig,
|
|
ChatMessage,
|
|
AISessionScope,
|
|
WebSearchConfig,
|
|
} from '../../infrastructure/ai/types';
|
|
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
|
import {
|
|
activateDraftView,
|
|
clearScopeDraftState,
|
|
ensureDraftForScopeState,
|
|
pruneStaleSessionPanelViews,
|
|
setDraftView,
|
|
setSessionView,
|
|
updateDraftForScope,
|
|
} from './aiDraftState';
|
|
import { convertFilesToUploads } from './useFileUpload';
|
|
import { removeProviderReferences } from './aiProviderCleanup';
|
|
|
|
import {
|
|
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
|
|
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
|
|
bumpDraftMutationVersion,
|
|
bumpDraftUploadGeneration,
|
|
cleanupSdkAgentSessions,
|
|
cleanupOrphanedAISessions,
|
|
getAIBridge,
|
|
getDraftUploadGeneration,
|
|
latestAIActiveSessionMapSnapshot,
|
|
latestAIDraftsByScopeSnapshot,
|
|
latestAIPanelViewByScopeSnapshot,
|
|
latestAISessionsSnapshot,
|
|
pruneSessionsForStorage,
|
|
setLatestAIActiveSessionMapSnapshot,
|
|
setLatestAIDraftsByScopeSnapshot,
|
|
setLatestAIPanelViewByScopeSnapshot,
|
|
setLatestAISessionsSnapshot,
|
|
type DraftsByScope,
|
|
type PanelViewByScope,
|
|
} from './aiStateSnapshots';
|
|
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
|
export function useAIState() {
|
|
// ── Provider Config ──
|
|
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
|
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
|
|
);
|
|
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
|
|
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
|
|
);
|
|
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
|
|
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
|
|
);
|
|
|
|
// ── Permission Model ──
|
|
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(() => {
|
|
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
|
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
|
|
return 'confirm';
|
|
});
|
|
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(() => {
|
|
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
|
|
return stored === 'skills' ? 'skills' : 'mcp';
|
|
});
|
|
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
|
|
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
|
|
);
|
|
|
|
// ── External Agents ──
|
|
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
|
|
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
|
|
);
|
|
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
|
|
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
|
|
);
|
|
|
|
// ── Safety Settings ──
|
|
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
|
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
|
);
|
|
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
|
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
|
|
);
|
|
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
|
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
|
);
|
|
|
|
// ── Sessions ──
|
|
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
|
latestAISessionsSnapshot
|
|
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
|
?? []
|
|
);
|
|
// Ref that always holds the latest sessions for use inside debounced callbacks
|
|
const sessionsRef = useRef(sessions);
|
|
useEffect(() => {
|
|
sessionsRef.current = sessions;
|
|
}, [sessions]);
|
|
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
|
|
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
|
|
latestAIActiveSessionMapSnapshot
|
|
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
|
?? {}
|
|
);
|
|
// Per-scope draft/view state is intentionally memory-only so a relaunch
|
|
// does not restore stale composer input or panel intent against new history.
|
|
const [draftsByScope, setDraftsByScopeRaw] = useState<DraftsByScope>(() =>
|
|
latestAIDraftsByScopeSnapshot ?? {}
|
|
);
|
|
const [panelViewByScope, setPanelViewByScopeRaw] = useState<PanelViewByScope>(() =>
|
|
latestAIPanelViewByScopeSnapshot ?? {}
|
|
);
|
|
|
|
// Per-agent model selection: remembers last selected model per agent
|
|
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
|
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
|
|
);
|
|
const agentModelMapRef = useRef(agentModelMap);
|
|
useEffect(() => {
|
|
agentModelMapRef.current = agentModelMap;
|
|
}, [agentModelMap]);
|
|
// Per-agent provider override: remembers which provider config each agent
|
|
// should bind to. Falls back to the global `activeProviderId` when an agent
|
|
// has no entry. Used so that e.g. Catty Agent can stay on DeepSeek while
|
|
// a Claude/Codex run continues on its existing provider.
|
|
const [agentProviderMap, setAgentProviderMapRaw] = useState<Record<string, string>>(() =>
|
|
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {}
|
|
);
|
|
// Mirror for non-functional reads inside removeProvider — needed to know
|
|
// which agents were bound to the deleted provider so we can also drop
|
|
// their saved model ids (those ids belonged to the now-missing provider).
|
|
const agentProviderMapRef = useRef(agentProviderMap);
|
|
useEffect(() => {
|
|
agentProviderMapRef.current = agentProviderMap;
|
|
}, [agentProviderMap]);
|
|
|
|
// ── Web Search Config ──
|
|
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
|
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
|
);
|
|
|
|
// ── Quick Messages (slash prompts) ──
|
|
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
|
|
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
|
|
);
|
|
|
|
useEffect(() => {
|
|
setLatestAISessionsSnapshot(sessions);
|
|
}, [sessions]);
|
|
|
|
useEffect(() => {
|
|
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
|
}, [activeSessionIdMap]);
|
|
|
|
useEffect(() => {
|
|
setLatestAIDraftsByScopeSnapshot(draftsByScope);
|
|
}, [draftsByScope]);
|
|
|
|
useEffect(() => {
|
|
setLatestAIPanelViewByScopeSnapshot(panelViewByScope);
|
|
}, [panelViewByScope]);
|
|
|
|
useEffect(() => {
|
|
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
|
|
let changed = false;
|
|
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
|
|
|
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap) as Array<[string, string | null]>) {
|
|
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
|
|
nextActiveSessionIdMap[scopeKey] = nextSessionId;
|
|
if (nextSessionId !== sessionId) {
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
|
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
|
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
|
}
|
|
|
|
setPanelViewByScopeRaw((prev) => {
|
|
const next = pruneStaleSessionPanelViews(prev, validSessionIds);
|
|
if (next === prev) {
|
|
return prev;
|
|
}
|
|
setLatestAIPanelViewByScopeSnapshot(next);
|
|
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
|
return next;
|
|
});
|
|
}, [sessions, activeSessionIdMap]);
|
|
|
|
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
|
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
|
|
|
setActiveSessionIdMapRaw(prev => {
|
|
if (prev[scopeKey] === id) {
|
|
return prev;
|
|
}
|
|
|
|
const next = { ...prev, [scopeKey]: id };
|
|
nextActiveSessionIdMap = next;
|
|
return next;
|
|
});
|
|
|
|
if (!nextActiveSessionIdMap) return;
|
|
|
|
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
|
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
|
}, []);
|
|
|
|
const setPanelViewByScope = useCallback((value: PanelViewByScope | ((prev: PanelViewByScope) => PanelViewByScope)) => {
|
|
let nextPanelViewByScope: PanelViewByScope | null = null;
|
|
|
|
setPanelViewByScopeRaw((prev) => {
|
|
const next = typeof value === 'function' ? value(prev) : value;
|
|
if (next === prev) return prev;
|
|
nextPanelViewByScope = next;
|
|
return next;
|
|
});
|
|
|
|
if (!nextPanelViewByScope) return;
|
|
|
|
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
|
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
|
}, []);
|
|
|
|
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
|
setAgentModelMapRaw(prev => {
|
|
const next = { ...prev, [agentId]: modelId };
|
|
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const setAgentProvider = useCallback((agentId: string, providerId: string) => {
|
|
setAgentProviderMapRaw(prev => {
|
|
// Empty string clears the per-agent override and lets the agent fall
|
|
// back to the global `activeProviderId`.
|
|
const next = { ...prev };
|
|
if (providerId) {
|
|
next[agentId] = providerId;
|
|
} else {
|
|
delete next[agentId];
|
|
}
|
|
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
|
setWebSearchConfigRaw(config);
|
|
if (config) {
|
|
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
|
|
} else {
|
|
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
|
|
}
|
|
}, []);
|
|
|
|
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
|
|
setQuickMessagesRaw((prev) => {
|
|
const nextRaw = typeof value === 'function' ? value(prev) : value;
|
|
const next = sanitizeQuickMessages(nextRaw);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
|
|
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// ── Persist helpers ──
|
|
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
|
setProvidersRaw(prev => {
|
|
const next = typeof value === 'function' ? value(prev) : value;
|
|
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const setActiveProviderId = useCallback((id: string) => {
|
|
setActiveProviderIdRaw(id);
|
|
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
|
|
}, []);
|
|
|
|
const setActiveModelId = useCallback((id: string) => {
|
|
setActiveModelIdRaw(id);
|
|
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
|
|
}, []);
|
|
|
|
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
|
|
setGlobalPermissionModeRaw(mode);
|
|
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
|
|
// Sync to MCP Server bridge (observer mode blocks write operations)
|
|
const bridge = getAIBridge();
|
|
bridge?.aiMcpSetPermissionMode?.(mode);
|
|
}, []);
|
|
|
|
const setHostPermissions = useCallback((value: HostAIPermission[] | ((prev: HostAIPermission[]) => HostAIPermission[])) => {
|
|
setHostPermissionsRaw(prev => {
|
|
const next = typeof value === 'function' ? value(prev) : value;
|
|
localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
|
|
setToolIntegrationModeRaw(mode);
|
|
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
|
|
const bridge = getAIBridge();
|
|
bridge?.aiMcpSetToolIntegrationMode?.(mode);
|
|
}, []);
|
|
|
|
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
|
|
setExternalAgentsRaw(prev => {
|
|
const next = typeof value === 'function' ? value(prev) : value;
|
|
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const setDefaultAgentId = useCallback((id: string) => {
|
|
setDefaultAgentIdRaw(id);
|
|
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
|
|
}, []);
|
|
|
|
const setCommandBlocklist = useCallback((value: string[]) => {
|
|
setCommandBlocklistRaw(value);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
|
// Sync to MCP Server bridge so SDK agents also respect the blocklist
|
|
const bridge = getAIBridge();
|
|
bridge?.aiMcpSetCommandBlocklist?.(value);
|
|
}, []);
|
|
|
|
const setCommandTimeout = useCallback((value: number) => {
|
|
setCommandTimeoutRaw(value);
|
|
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
|
|
// Sync to MCP Server bridge
|
|
const bridge = getAIBridge();
|
|
bridge?.aiMcpSetCommandTimeout?.(value);
|
|
}, []);
|
|
|
|
const setMaxIterations = useCallback((value: number) => {
|
|
setMaxIterationsRaw(value);
|
|
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
|
|
// Sync to MCP Server bridge (used by SDK agent path)
|
|
const bridge = getAIBridge();
|
|
bridge?.aiMcpSetMaxIterations?.(value);
|
|
}, []);
|
|
|
|
// ── Cross-window sync via storage events ──
|
|
// When the settings window updates localStorage, the main window picks up changes.
|
|
useEffect(() => {
|
|
const handleStorage = (e: StorageEvent) => {
|
|
try {
|
|
switch (e.key) {
|
|
case STORAGE_KEY_AI_PROVIDERS: {
|
|
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
|
if (parsed != null && !Array.isArray(parsed)) {
|
|
console.warn('[useAIState] Cross-window sync: AI_PROVIDERS is not an array, skipping');
|
|
break;
|
|
}
|
|
setProvidersRaw(parsed ?? []);
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
|
|
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
|
|
break;
|
|
case STORAGE_KEY_AI_ACTIVE_MODEL:
|
|
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
|
|
break;
|
|
case STORAGE_KEY_AI_PERMISSION_MODE: {
|
|
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
|
if (mode === 'observer' || mode === 'confirm' || mode === 'autonomous') {
|
|
setGlobalPermissionModeRaw(mode);
|
|
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
|
|
}
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
|
|
{
|
|
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
|
|
? 'skills'
|
|
: 'mcp';
|
|
setToolIntegrationModeRaw(mode);
|
|
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
|
|
}
|
|
break;
|
|
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
|
|
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
|
|
if (agents != null && !Array.isArray(agents)) {
|
|
console.warn('[useAIState] Cross-window sync: AI_EXTERNAL_AGENTS is not an array, skipping');
|
|
break;
|
|
}
|
|
setExternalAgentsRaw(agents ?? []);
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_DEFAULT_AGENT:
|
|
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
|
break;
|
|
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
|
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
|
if (list != null && !Array.isArray(list)) {
|
|
console.warn('[useAIState] Cross-window sync: AI_COMMAND_BLOCKLIST is not an array, skipping');
|
|
break;
|
|
}
|
|
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
|
setCommandBlocklistRaw(blocklist);
|
|
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
|
|
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
|
if (!Number.isFinite(timeout)) {
|
|
console.warn('[useAIState] Cross-window sync: AI_COMMAND_TIMEOUT is not a finite number, skipping');
|
|
break;
|
|
}
|
|
setCommandTimeoutRaw(timeout);
|
|
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_MAX_ITERATIONS: {
|
|
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
|
if (!Number.isFinite(iters)) {
|
|
console.warn('[useAIState] Cross-window sync: AI_MAX_ITERATIONS is not a finite number, skipping');
|
|
break;
|
|
}
|
|
setMaxIterationsRaw(iters);
|
|
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_HOST_PERMISSIONS: {
|
|
const perms = localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS);
|
|
if (perms != null && !Array.isArray(perms)) {
|
|
console.warn('[useAIState] Cross-window sync: AI_HOST_PERMISSIONS is not an array, skipping');
|
|
break;
|
|
}
|
|
setHostPermissionsRaw(perms ?? []);
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_SESSIONS: {
|
|
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
|
setLatestAISessionsSnapshot(nextSessions);
|
|
setSessionsRaw(nextSessions);
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
|
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
|
break;
|
|
case STORAGE_KEY_AI_AGENT_PROVIDER_MAP:
|
|
setAgentProviderMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {});
|
|
break;
|
|
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
|
|
const nextActiveSessionIdMap =
|
|
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
|
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
|
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_WEB_SEARCH:
|
|
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
|
break;
|
|
case STORAGE_KEY_AI_QUICK_MESSAGES: {
|
|
const messages = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES);
|
|
setQuickMessagesRaw(sanitizeQuickMessages(messages));
|
|
break;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
|
|
}
|
|
};
|
|
window.addEventListener('storage', handleStorage);
|
|
const handleLocalStateChanged = (event: Event) => {
|
|
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
|
if (!key) return;
|
|
switch (key) {
|
|
case STORAGE_KEY_AI_SESSIONS:
|
|
setSessionsRaw(
|
|
latestAISessionsSnapshot
|
|
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
|
?? [],
|
|
);
|
|
return;
|
|
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
|
|
setActiveSessionIdMapRaw(
|
|
latestAIActiveSessionMapSnapshot
|
|
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
|
?? {},
|
|
);
|
|
return;
|
|
case AI_STATE_CHANGED_DRAFTS_BY_SCOPE:
|
|
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
|
|
return;
|
|
case AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE:
|
|
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
|
return;
|
|
default:
|
|
handleStorage({ key } as StorageEvent);
|
|
}
|
|
};
|
|
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
|
return () => {
|
|
window.removeEventListener('storage', handleStorage);
|
|
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
|
};
|
|
}, []);
|
|
|
|
// ── Sync initial safety settings to MCP Server on mount ──
|
|
useEffect(() => {
|
|
const bridge = getAIBridge();
|
|
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
|
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
|
|
const initialTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
|
|
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
|
|
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
|
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
|
|
const storedPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
|
const initialPermMode: AIPermissionMode =
|
|
storedPermMode === 'observer' || storedPermMode === 'confirm' || storedPermMode === 'autonomous'
|
|
? storedPermMode
|
|
: 'confirm';
|
|
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
|
|
const initialToolMode: AIToolIntegrationMode =
|
|
localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
|
|
? 'skills'
|
|
: 'mcp';
|
|
bridge?.aiMcpSetToolIntegrationMode?.(initialToolMode);
|
|
}, []);
|
|
|
|
// ── Session CRUD ──
|
|
const persistSessions = useCallback((next: AISession[]) => {
|
|
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
|
|
}, []);
|
|
|
|
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
|
|
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const mountedRef = useRef(true);
|
|
|
|
const debouncedPersistSessions = useCallback(() => {
|
|
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
|
persistTimerRef.current = setTimeout(() => {
|
|
if (!mountedRef.current) return; // Skip writes after unmount
|
|
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
|
|
persistTimerRef.current = null;
|
|
}, 500);
|
|
}, []);
|
|
|
|
// Flush pending debounced writes on unmount
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
if (persistTimerRef.current) {
|
|
clearTimeout(persistTimerRef.current);
|
|
persistTimerRef.current = null;
|
|
persistSessions(sessionsRef.current);
|
|
}
|
|
};
|
|
}, [persistSessions]);
|
|
|
|
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
|
|
const now = Date.now();
|
|
const session: AISession = {
|
|
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
|
title: 'New Chat',
|
|
agentId: agentId || defaultAgentId,
|
|
scope,
|
|
messages: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
setSessionsRaw(prev => {
|
|
const next = [session, ...prev];
|
|
setLatestAISessionsSnapshot(next);
|
|
persistSessions(next);
|
|
return next;
|
|
});
|
|
const scopeKey = `${scope.type}:${scope.targetId ?? ''}`;
|
|
setActiveSessionId(scopeKey, session.id);
|
|
return session;
|
|
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
|
|
|
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
|
cleanupSdkAgentSessions([sessionId]);
|
|
if (persistTimerRef.current) {
|
|
clearTimeout(persistTimerRef.current);
|
|
persistTimerRef.current = null;
|
|
}
|
|
setSessionsRaw(prev => {
|
|
const next = prev.filter(s => s.id !== sessionId);
|
|
setLatestAISessionsSnapshot(next);
|
|
persistSessions(next);
|
|
return next;
|
|
});
|
|
if (scopeKey) {
|
|
setActiveSessionIdMapRaw(prev => {
|
|
if (prev[scopeKey] === sessionId) {
|
|
const next = { ...prev, [scopeKey]: null };
|
|
setLatestAIActiveSessionMapSnapshot(next);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
|
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
|
return next;
|
|
}
|
|
return prev;
|
|
});
|
|
setPanelViewByScopeRaw((prev) => {
|
|
const currentPanelView = prev[scopeKey];
|
|
if (currentPanelView?.mode !== 'session' || currentPanelView.sessionId !== sessionId) {
|
|
return prev;
|
|
}
|
|
const next = setDraftView(prev, scopeKey);
|
|
if (next === prev) {
|
|
return prev;
|
|
}
|
|
setLatestAIPanelViewByScopeSnapshot(next);
|
|
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
|
return next;
|
|
});
|
|
}
|
|
}, [persistSessions]);
|
|
|
|
const deleteSessionsByTarget = useCallback((scopeType: 'terminal' | 'workspace', targetId: string) => {
|
|
const removedSessionIds = sessionsRef.current
|
|
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
|
|
.map(s => s.id);
|
|
cleanupSdkAgentSessions(removedSessionIds);
|
|
if (persistTimerRef.current) {
|
|
clearTimeout(persistTimerRef.current);
|
|
persistTimerRef.current = null;
|
|
}
|
|
setSessionsRaw(prev => {
|
|
const next = prev.filter(s => {
|
|
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
|
});
|
|
setLatestAISessionsSnapshot(next);
|
|
persistSessions(next);
|
|
return next;
|
|
});
|
|
const scopeKey = `${scopeType}:${targetId}`;
|
|
setActiveSessionIdMapRaw(prev => {
|
|
if (prev[scopeKey] != null) {
|
|
const next = { ...prev, [scopeKey]: null };
|
|
setLatestAIActiveSessionMapSnapshot(next);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
|
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
|
return next;
|
|
}
|
|
return prev;
|
|
});
|
|
}, [persistSessions]);
|
|
|
|
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
|
|
setSessionsRaw(prev => {
|
|
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
|
|
setLatestAISessionsSnapshot(next);
|
|
persistSessions(next);
|
|
return next;
|
|
});
|
|
}, [persistSessions]);
|
|
|
|
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
|
|
setSessionsRaw(prev => {
|
|
const next = prev.map(s => (
|
|
s.id === sessionId
|
|
? { ...s, externalSessionId, updatedAt: Date.now() }
|
|
: s
|
|
));
|
|
setLatestAISessionsSnapshot(next);
|
|
debouncedPersistSessions();
|
|
return next;
|
|
});
|
|
}, [debouncedPersistSessions]);
|
|
|
|
// Maximum messages per session to prevent unbounded memory growth
|
|
const MAX_MESSAGES_PER_SESSION = 500;
|
|
|
|
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
|
|
setSessionsRaw(prev => {
|
|
const next = prev.map(s => {
|
|
if (s.id !== sessionId) return s;
|
|
let msgs = [...s.messages, message];
|
|
// Trim oldest messages if exceeding limit (keep system messages)
|
|
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
|
|
const systemMsgs = msgs.filter(m => m.role === 'system');
|
|
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
|
|
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
|
|
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
|
|
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
|
|
}
|
|
return { ...s, messages: msgs, updatedAt: Date.now() };
|
|
});
|
|
setLatestAISessionsSnapshot(next);
|
|
debouncedPersistSessions();
|
|
return next;
|
|
});
|
|
}, [debouncedPersistSessions]);
|
|
|
|
const updateLastMessage = useCallback((sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
|
setSessionsRaw(prev => {
|
|
const next = prev.map(s => {
|
|
if (s.id !== sessionId || s.messages.length === 0) return s;
|
|
const msgs = [...s.messages];
|
|
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
|
|
return { ...s, messages: msgs, updatedAt: Date.now() };
|
|
});
|
|
setLatestAISessionsSnapshot(next);
|
|
debouncedPersistSessions();
|
|
return next;
|
|
});
|
|
}, [debouncedPersistSessions]);
|
|
|
|
const updateMessageById = useCallback((sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
|
setSessionsRaw(prev => {
|
|
const next = prev.map(s => {
|
|
if (s.id !== sessionId) return s;
|
|
const idx = s.messages.findIndex(m => m.id === messageId);
|
|
if (idx === -1) return s;
|
|
const msgs = [...s.messages];
|
|
msgs[idx] = updater(msgs[idx]);
|
|
return { ...s, messages: msgs, updatedAt: Date.now() };
|
|
});
|
|
setLatestAISessionsSnapshot(next);
|
|
debouncedPersistSessions();
|
|
return next;
|
|
});
|
|
}, [debouncedPersistSessions]);
|
|
|
|
const clearSessionMessages = useCallback((sessionId: string) => {
|
|
if (persistTimerRef.current) {
|
|
clearTimeout(persistTimerRef.current);
|
|
persistTimerRef.current = null;
|
|
}
|
|
setSessionsRaw(prev => {
|
|
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
|
|
setLatestAISessionsSnapshot(next);
|
|
persistSessions(next);
|
|
return next;
|
|
});
|
|
}, [persistSessions]);
|
|
|
|
const ensureDraftForScope = useCallback((scopeKey: string, agentId: string): void => {
|
|
let nextDraftsByScope: DraftsByScope | null = null;
|
|
|
|
setDraftsByScopeRaw((prev) => {
|
|
const next = ensureDraftForScopeState(prev, scopeKey, agentId);
|
|
if (next === prev) return prev;
|
|
nextDraftsByScope = next;
|
|
return next;
|
|
});
|
|
|
|
if (!nextDraftsByScope) return;
|
|
|
|
bumpDraftMutationVersion(scopeKey);
|
|
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
|
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
|
}, []);
|
|
|
|
const updateDraft = useCallback((
|
|
scopeKey: string,
|
|
fallbackAgentId: string,
|
|
updater: (draft: AIDraft) => AIDraft,
|
|
): void => {
|
|
setDraftsByScopeRaw((prev) => {
|
|
const next = updateDraftForScope(
|
|
prev,
|
|
scopeKey,
|
|
fallbackAgentId,
|
|
(draft) => {
|
|
return {
|
|
...updater(draft),
|
|
updatedAt: Date.now(),
|
|
};
|
|
},
|
|
);
|
|
setLatestAIDraftsByScopeSnapshot(next);
|
|
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
|
return next;
|
|
});
|
|
bumpDraftMutationVersion(scopeKey);
|
|
}, []);
|
|
|
|
const updateDraftIfPresent = useCallback((
|
|
scopeKey: string,
|
|
updater: (draft: AIDraft) => AIDraft,
|
|
): void => {
|
|
let updated = false;
|
|
|
|
setDraftsByScopeRaw((prev) => {
|
|
const currentDraft = prev[scopeKey];
|
|
if (!currentDraft) return prev;
|
|
|
|
const nextDraft = {
|
|
...updater(currentDraft),
|
|
updatedAt: Date.now(),
|
|
};
|
|
const next = {
|
|
...prev,
|
|
[scopeKey]: nextDraft,
|
|
};
|
|
updated = true;
|
|
setLatestAIDraftsByScopeSnapshot(next);
|
|
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
|
return next;
|
|
});
|
|
|
|
if (updated) {
|
|
bumpDraftMutationVersion(scopeKey);
|
|
}
|
|
}, []);
|
|
|
|
const showDraftView = useCallback((scopeKey: string) => {
|
|
const currentPanelViewByScope = panelViewByScope;
|
|
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
|
let nextPanelViewByScope: PanelViewByScope | null = null;
|
|
let activeSessionMapChanged = false;
|
|
let panelViewChanged = false;
|
|
|
|
setActiveSessionIdMapRaw((prevActiveSessionIdMap) => {
|
|
const next = activateDraftView(
|
|
prevActiveSessionIdMap,
|
|
currentPanelViewByScope,
|
|
scopeKey,
|
|
);
|
|
activeSessionMapChanged = next.activeSessionIdMap !== prevActiveSessionIdMap;
|
|
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
|
nextActiveSessionIdMap = next.activeSessionIdMap;
|
|
nextPanelViewByScope = next.panelViewByScope;
|
|
return activeSessionMapChanged ? next.activeSessionIdMap : prevActiveSessionIdMap;
|
|
});
|
|
|
|
if (activeSessionMapChanged && nextActiveSessionIdMap) {
|
|
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
|
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
|
}
|
|
|
|
if (panelViewChanged && nextPanelViewByScope) {
|
|
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
|
setPanelViewByScopeRaw(nextPanelViewByScope);
|
|
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
|
}
|
|
}, [panelViewByScope]);
|
|
|
|
const showSessionView = useCallback((scopeKey: string, sessionId: string) => {
|
|
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
|
|
}, [setPanelViewByScope]);
|
|
|
|
const clearDraftForScope = useCallback((scopeKey: string) => {
|
|
const currentPanelViewByScope = panelViewByScope;
|
|
let nextDraftsByScope: DraftsByScope | null = null;
|
|
let nextPanelViewByScope: PanelViewByScope | null = null;
|
|
let draftsChanged = false;
|
|
let panelViewChanged = false;
|
|
|
|
setDraftsByScopeRaw((prevDraftsByScope) => {
|
|
const next = clearScopeDraftState(
|
|
prevDraftsByScope,
|
|
currentPanelViewByScope,
|
|
scopeKey,
|
|
);
|
|
draftsChanged = next.draftsByScope !== prevDraftsByScope;
|
|
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
|
nextDraftsByScope = next.draftsByScope;
|
|
nextPanelViewByScope = next.panelViewByScope;
|
|
return draftsChanged ? next.draftsByScope : prevDraftsByScope;
|
|
});
|
|
|
|
if (!draftsChanged && !panelViewChanged) return;
|
|
|
|
bumpDraftMutationVersion(scopeKey);
|
|
bumpDraftUploadGeneration(scopeKey);
|
|
|
|
if (draftsChanged && nextDraftsByScope) {
|
|
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
|
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
|
}
|
|
|
|
if (panelViewChanged && nextPanelViewByScope) {
|
|
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
|
setPanelViewByScopeRaw(nextPanelViewByScope);
|
|
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
|
}
|
|
}, [panelViewByScope]);
|
|
|
|
const addDraftFiles = useCallback(async (
|
|
scopeKey: string,
|
|
fallbackAgentId: string,
|
|
inputFiles: File[],
|
|
) => {
|
|
ensureDraftForScope(scopeKey, fallbackAgentId);
|
|
const initialUploadGeneration = getDraftUploadGeneration(scopeKey);
|
|
const uploads = await convertFilesToUploads(inputFiles);
|
|
if (uploads.length === 0) return;
|
|
|
|
if (getDraftUploadGeneration(scopeKey) !== initialUploadGeneration) {
|
|
return;
|
|
}
|
|
|
|
updateDraftIfPresent(scopeKey, (draft) => ({
|
|
...draft,
|
|
attachments: [...draft.attachments, ...uploads],
|
|
}));
|
|
}, [ensureDraftForScope, updateDraftIfPresent]);
|
|
|
|
const removeDraftFile = useCallback((scopeKey: string, fallbackAgentId: string, fileId: string) => {
|
|
updateDraft(scopeKey, fallbackAgentId, (draft) => ({
|
|
...draft,
|
|
attachments: draft.attachments.filter((file) => file.id !== fileId),
|
|
}));
|
|
}, [updateDraft]);
|
|
|
|
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
|
cleanupOrphanedAISessions(activeTargetIds);
|
|
|
|
const nextSessions =
|
|
latestAISessionsSnapshot
|
|
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
|
?? [];
|
|
sessionsRef.current = nextSessions;
|
|
setSessionsRaw(nextSessions);
|
|
setActiveSessionIdMapRaw(
|
|
latestAIActiveSessionMapSnapshot
|
|
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
|
?? {},
|
|
);
|
|
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
|
|
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
|
}, []);
|
|
|
|
// ── Provider CRUD helpers ──
|
|
const addProvider = useCallback((provider: ProviderConfig) => {
|
|
setProviders(prev => [...prev, provider]);
|
|
}, [setProviders]);
|
|
|
|
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
|
|
setProviders(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p));
|
|
}, [setProviders]);
|
|
|
|
const removeProvider = useCallback((id: string) => {
|
|
setProviders(prev => prev.filter(p => p.id !== id));
|
|
setActiveProviderIdRaw(prevId => {
|
|
if (prevId === id) {
|
|
const next = '';
|
|
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, next);
|
|
return next;
|
|
}
|
|
return prevId;
|
|
});
|
|
const cleanup = removeProviderReferences(
|
|
id,
|
|
agentProviderMapRef.current,
|
|
agentModelMapRef.current,
|
|
);
|
|
if (cleanup.providerMapChanged) {
|
|
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, cleanup.agentProviderMap);
|
|
setAgentProviderMapRaw(cleanup.agentProviderMap);
|
|
}
|
|
if (cleanup.modelMapChanged) {
|
|
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, cleanup.agentModelMap);
|
|
setAgentModelMapRaw(cleanup.agentModelMap);
|
|
}
|
|
}, [setProviders]);
|
|
|
|
// ── Computed ──
|
|
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
|
|
|
|
return useMemo(() => ({
|
|
providers,
|
|
setProviders,
|
|
addProvider,
|
|
updateProvider,
|
|
removeProvider,
|
|
activeProviderId,
|
|
setActiveProviderId,
|
|
activeModelId,
|
|
setActiveModelId,
|
|
activeProvider,
|
|
globalPermissionMode,
|
|
setGlobalPermissionMode,
|
|
toolIntegrationMode,
|
|
setToolIntegrationMode,
|
|
hostPermissions,
|
|
setHostPermissions,
|
|
externalAgents,
|
|
setExternalAgents,
|
|
defaultAgentId,
|
|
setDefaultAgentId,
|
|
commandBlocklist,
|
|
setCommandBlocklist,
|
|
commandTimeout,
|
|
setCommandTimeout,
|
|
maxIterations,
|
|
setMaxIterations,
|
|
agentModelMap,
|
|
setAgentModel,
|
|
agentProviderMap,
|
|
setAgentProvider,
|
|
webSearchConfig,
|
|
setWebSearchConfig,
|
|
quickMessages,
|
|
setQuickMessages,
|
|
sessions,
|
|
activeSessionIdMap,
|
|
draftsByScope,
|
|
panelViewByScope,
|
|
setActiveSessionId,
|
|
ensureDraftForScope,
|
|
updateDraft,
|
|
showDraftView,
|
|
showSessionView,
|
|
clearDraftForScope,
|
|
addDraftFiles,
|
|
removeDraftFile,
|
|
createSession,
|
|
deleteSession,
|
|
deleteSessionsByTarget,
|
|
updateSessionTitle,
|
|
updateSessionExternalSessionId,
|
|
addMessageToSession,
|
|
updateLastMessage,
|
|
updateMessageById,
|
|
clearSessionMessages,
|
|
cleanupOrphanedSessions,
|
|
}), [
|
|
providers,
|
|
setProviders,
|
|
addProvider,
|
|
updateProvider,
|
|
removeProvider,
|
|
activeProviderId,
|
|
setActiveProviderId,
|
|
activeModelId,
|
|
setActiveModelId,
|
|
activeProvider,
|
|
globalPermissionMode,
|
|
setGlobalPermissionMode,
|
|
toolIntegrationMode,
|
|
setToolIntegrationMode,
|
|
hostPermissions,
|
|
setHostPermissions,
|
|
externalAgents,
|
|
setExternalAgents,
|
|
defaultAgentId,
|
|
setDefaultAgentId,
|
|
commandBlocklist,
|
|
setCommandBlocklist,
|
|
commandTimeout,
|
|
setCommandTimeout,
|
|
maxIterations,
|
|
setMaxIterations,
|
|
agentModelMap,
|
|
setAgentModel,
|
|
agentProviderMap,
|
|
setAgentProvider,
|
|
webSearchConfig,
|
|
setWebSearchConfig,
|
|
quickMessages,
|
|
setQuickMessages,
|
|
sessions,
|
|
activeSessionIdMap,
|
|
draftsByScope,
|
|
panelViewByScope,
|
|
setActiveSessionId,
|
|
ensureDraftForScope,
|
|
updateDraft,
|
|
showDraftView,
|
|
showSessionView,
|
|
clearDraftForScope,
|
|
addDraftFiles,
|
|
removeDraftFile,
|
|
createSession,
|
|
deleteSession,
|
|
deleteSessionsByTarget,
|
|
updateSessionTitle,
|
|
updateSessionExternalSessionId,
|
|
addMessageToSession,
|
|
updateLastMessage,
|
|
updateMessageById,
|
|
clearSessionMessages,
|
|
cleanupOrphanedSessions,
|
|
]);
|
|
}
|