Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1352 lines
50 KiB
TypeScript
1352 lines
50 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, 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_RESPONSE_IDLE_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_AGENT_THINKING_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,
|
|
AISessionContextCompaction,
|
|
AISession,
|
|
AIPermissionMode,
|
|
AIToolIntegrationMode,
|
|
ProviderConfig,
|
|
HostAIPermission,
|
|
ExternalAgentConfig,
|
|
ChatMessage,
|
|
AISessionScope,
|
|
WebSearchConfig,
|
|
} from '../../infrastructure/ai/types';
|
|
import {
|
|
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
|
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
|
normalizeCommandTimeoutSeconds,
|
|
normalizeResponseIdleTimeoutSeconds,
|
|
} from '../../infrastructure/ai/types';
|
|
import {
|
|
activateDraftView,
|
|
clearScopeDraftState,
|
|
draftsByScopeEqualIgnoringAllComposerText,
|
|
ensureDraftForScopeState,
|
|
pruneStaleSessionPanelViews,
|
|
setDraftView,
|
|
setSessionView,
|
|
updateDraftForScope,
|
|
} from './aiDraftState';
|
|
import { convertFilesToUploads } from './useFileUpload';
|
|
import { removeProviderReferences } from './aiProviderCleanup';
|
|
import { publishAISessionsSnapshot } from './aiSessionsStore';
|
|
import {
|
|
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
|
|
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
|
|
bumpDraftMutationVersion,
|
|
bumpDraftUploadGeneration,
|
|
cleanupDeletedAIChatSessions,
|
|
cleanupOrphanedAISessions,
|
|
getAIBridge,
|
|
getDraftUploadGeneration,
|
|
latestAIActiveSessionMapSnapshot,
|
|
latestAIDraftsByScopeSnapshot,
|
|
latestAIPanelViewByScopeSnapshot,
|
|
latestAISessionsSnapshot,
|
|
writeSessionsForStorage,
|
|
setLatestAIActiveSessionMapSnapshot,
|
|
setLatestAIDraftsByScopeSnapshot,
|
|
setLatestAIPanelViewByScopeSnapshot,
|
|
setLatestAISessionsSnapshot,
|
|
type DraftsByScope,
|
|
type PanelViewByScope,
|
|
} from './aiStateSnapshots';
|
|
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
|
import {
|
|
persistCommandBlocklistSetting,
|
|
readCommandBlocklistSetting,
|
|
} from './commandBlocklistSettings';
|
|
import {
|
|
handoffDissolvedWorkspaceAIScope,
|
|
retargetWorkspaceActiveChatAfterMemberLoss,
|
|
seedWorkspaceAIActiveSessionFromMembers,
|
|
} from '../../domain/workspaceAiScopeHandoff';
|
|
|
|
function providerPatchIsNoop(
|
|
current: ProviderConfig,
|
|
updates: Partial<ProviderConfig>,
|
|
): boolean {
|
|
for (const key of Object.keys(updates) as Array<keyof ProviderConfig>) {
|
|
const nextValue = updates[key];
|
|
const prevValue = current[key];
|
|
if (nextValue === prevValue) continue;
|
|
if (key === 'modelContextWindows') {
|
|
const prevWindows = (prevValue ?? {}) as Record<string, number>;
|
|
const nextWindows = (nextValue ?? {}) as Record<string, number>;
|
|
const nextKeys = Object.keys(nextWindows);
|
|
if (nextKeys.length !== Object.keys(prevWindows).length) return false;
|
|
if (nextKeys.some((modelId) => prevWindows[modelId] !== nextWindows[modelId])) return false;
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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 === 'auto') 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[]>(readCommandBlocklistSetting);
|
|
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
|
normalizeCommandTimeoutSeconds(
|
|
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
|
)
|
|
);
|
|
const [responseIdleTimeout, setResponseIdleTimeoutRaw] = useState<number>(() =>
|
|
normalizeResponseIdleTimeoutSeconds(
|
|
localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
|
|
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
|
)
|
|
);
|
|
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]);
|
|
const [agentThinkingMap, setAgentThinkingMapRaw] = useState<Record<string, string>>(() =>
|
|
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_MAP) ?? {}
|
|
);
|
|
|
|
// ── 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 => {
|
|
if (prev[agentId] === modelId) return 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`.
|
|
if (providerId) {
|
|
if (prev[agentId] === providerId) return prev;
|
|
const next = { ...prev, [agentId]: providerId };
|
|
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
|
|
return next;
|
|
}
|
|
if (!(agentId in prev)) return prev;
|
|
const next = { ...prev };
|
|
delete next[agentId];
|
|
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const setAgentThinking = useCallback((agentId: string, thinkingLevel: string) => {
|
|
setAgentThinkingMapRaw((prev) => {
|
|
if (thinkingLevel) {
|
|
if (prev[agentId] === thinkingLevel) return prev;
|
|
const next = { ...prev, [agentId]: thinkingLevel };
|
|
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_MAP, next);
|
|
return next;
|
|
}
|
|
if (!(agentId in prev)) return prev;
|
|
const next = { ...prev };
|
|
delete next[agentId];
|
|
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_THINKING_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;
|
|
if (next === prev) return prev;
|
|
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);
|
|
persistCommandBlocklistSetting(value);
|
|
// Sync to MCP Server bridge so SDK agents also respect the blocklist
|
|
const bridge = getAIBridge();
|
|
bridge?.aiMcpSetCommandBlocklist?.(value);
|
|
}, []);
|
|
|
|
const setCommandTimeout = useCallback((value: number) => {
|
|
const normalizedValue = normalizeCommandTimeoutSeconds(value);
|
|
setCommandTimeoutRaw(normalizedValue);
|
|
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, normalizedValue);
|
|
// Sync to MCP Server bridge
|
|
const bridge = getAIBridge();
|
|
bridge?.aiMcpSetCommandTimeout?.(normalizedValue);
|
|
}, []);
|
|
|
|
const setResponseIdleTimeout = useCallback((value: number) => {
|
|
const normalizedValue = normalizeResponseIdleTimeoutSeconds(value);
|
|
setResponseIdleTimeoutRaw(normalizedValue);
|
|
localStorageAdapter.writeNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, normalizedValue);
|
|
}, []);
|
|
|
|
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 === 'auto') {
|
|
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 blocklist = readCommandBlocklistSetting();
|
|
setCommandBlocklistRaw(blocklist);
|
|
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
|
|
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS;
|
|
if (!Number.isFinite(timeout)) {
|
|
console.warn('[useAIState] Cross-window sync: AI_COMMAND_TIMEOUT is not a finite number, skipping');
|
|
break;
|
|
}
|
|
const normalizedTimeout = normalizeCommandTimeoutSeconds(timeout);
|
|
setCommandTimeoutRaw(normalizedTimeout);
|
|
getAIBridge()?.aiMcpSetCommandTimeout?.(normalizedTimeout);
|
|
break;
|
|
}
|
|
case STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT: {
|
|
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT)
|
|
?? DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS;
|
|
if (!Number.isFinite(timeout)) {
|
|
console.warn('[useAIState] Cross-window sync: AI_RESPONSE_IDLE_TIMEOUT is not a finite number, skipping');
|
|
break;
|
|
}
|
|
setResponseIdleTimeoutRaw(normalizeResponseIdleTimeoutSeconds(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_AGENT_THINKING_MAP:
|
|
setAgentThinkingMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_THINKING_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 = readCommandBlocklistSetting();
|
|
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
|
|
const initialTimeout = normalizeCommandTimeoutSeconds(
|
|
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
|
);
|
|
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 === 'auto'
|
|
? 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[]) => {
|
|
writeSessionsForStorage(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
|
|
writeSessionsForStorage(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) => {
|
|
cleanupDeletedAIChatSessions([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);
|
|
cleanupDeletedAIChatSessions(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 persistContextCompaction = useCallback((
|
|
sessionId: string,
|
|
contextCompaction: AISessionContextCompaction,
|
|
) => {
|
|
setSessionsRaw(prev => {
|
|
const next = prev.map(s => s.id === sessionId
|
|
? { ...s, contextCompaction, updatedAt: Date.now() }
|
|
: s);
|
|
setLatestAISessionsSnapshot(next);
|
|
persistSessions(next);
|
|
return next;
|
|
});
|
|
}, [persistSessions]);
|
|
|
|
const ensureDraftForScope = useCallback((scopeKey: string, agentId: string): void => {
|
|
let nextDraftsByScope: DraftsByScope | null = null;
|
|
let textOnly = false;
|
|
|
|
setDraftsByScopeRaw((prev) => {
|
|
const next = ensureDraftForScopeState(prev, scopeKey, agentId);
|
|
if (next === prev) return prev;
|
|
nextDraftsByScope = next;
|
|
textOnly = draftsByScopeEqualIgnoringAllComposerText(prev, next);
|
|
return next;
|
|
});
|
|
|
|
if (!nextDraftsByScope) return;
|
|
|
|
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
|
if (textOnly) return;
|
|
bumpDraftMutationVersion(scopeKey);
|
|
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
|
}, []);
|
|
|
|
const updateDraft = useCallback((
|
|
scopeKey: string,
|
|
fallbackAgentId: string,
|
|
updater: (draft: AIDraft) => AIDraft,
|
|
): void => {
|
|
let nextDraftsByScope: DraftsByScope | null = null;
|
|
let textOnly = false;
|
|
|
|
setDraftsByScopeRaw((prev) => {
|
|
const next = updateDraftForScope(
|
|
prev,
|
|
scopeKey,
|
|
fallbackAgentId,
|
|
(draft) => {
|
|
const updated = updater(draft);
|
|
if (updated === draft) return draft;
|
|
return {
|
|
...updated,
|
|
updatedAt: Date.now(),
|
|
};
|
|
},
|
|
);
|
|
if (next === prev) return prev;
|
|
nextDraftsByScope = next;
|
|
textOnly = draftsByScopeEqualIgnoringAllComposerText(prev, next);
|
|
return next;
|
|
});
|
|
|
|
if (!nextDraftsByScope) return;
|
|
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
|
if (textOnly) return;
|
|
bumpDraftMutationVersion(scopeKey);
|
|
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
|
}, []);
|
|
|
|
const updateDraftIfPresent = useCallback((
|
|
scopeKey: string,
|
|
updater: (draft: AIDraft) => AIDraft,
|
|
): void => {
|
|
let nextDraftsByScope: DraftsByScope | null = null;
|
|
let textOnly = false;
|
|
|
|
setDraftsByScopeRaw((prev) => {
|
|
const currentDraft = prev[scopeKey];
|
|
if (!currentDraft) return prev;
|
|
|
|
const updated = updater(currentDraft);
|
|
const nextDraft = updated === currentDraft
|
|
? currentDraft
|
|
: {
|
|
...updated,
|
|
updatedAt: Date.now(),
|
|
};
|
|
if (nextDraft === currentDraft) return prev;
|
|
const next = {
|
|
...prev,
|
|
[scopeKey]: nextDraft,
|
|
};
|
|
nextDraftsByScope = next;
|
|
textOnly = draftsByScopeEqualIgnoringAllComposerText(prev, next);
|
|
return next;
|
|
});
|
|
|
|
if (!nextDraftsByScope) return;
|
|
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
|
if (textOnly) return;
|
|
bumpDraftMutationVersion(scopeKey);
|
|
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
|
}, []);
|
|
|
|
const showDraftView = useCallback((scopeKey: string) => {
|
|
const currentActiveSessionIdMap = latestAIActiveSessionMapSnapshot
|
|
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
|
?? {};
|
|
const next = activateDraftView(
|
|
currentActiveSessionIdMap,
|
|
panelViewByScope,
|
|
scopeKey,
|
|
);
|
|
|
|
if (next.activeSessionIdMap !== currentActiveSessionIdMap) {
|
|
setLatestAIActiveSessionMapSnapshot(next.activeSessionIdMap);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next.activeSessionIdMap);
|
|
setActiveSessionIdMapRaw(next.activeSessionIdMap);
|
|
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
|
}
|
|
|
|
if (next.panelViewByScope !== panelViewByScope) {
|
|
setLatestAIPanelViewByScopeSnapshot(next.panelViewByScope);
|
|
setPanelViewByScopeRaw(next.panelViewByScope);
|
|
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 ?? {});
|
|
}, []);
|
|
|
|
const seedWorkspaceActiveSessionFromMembers = useCallback((input: {
|
|
workspaceId: string;
|
|
memberTerminalIds: readonly string[];
|
|
preferredTerminalId?: string | null;
|
|
}) => {
|
|
const currentMap =
|
|
latestAIActiveSessionMapSnapshot
|
|
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
|
?? {};
|
|
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
|
const seeded = seedWorkspaceAIActiveSessionFromMembers({
|
|
activeSessionIdMap: currentMap,
|
|
panelViewByScope: currentPanelViewByScope,
|
|
workspaceId: input.workspaceId,
|
|
memberTerminalIds: input.memberTerminalIds,
|
|
preferredTerminalId: input.preferredTerminalId,
|
|
});
|
|
if (!seeded) return;
|
|
setLatestAIActiveSessionMapSnapshot(seeded.activeSessionIdMap);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, seeded.activeSessionIdMap);
|
|
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
|
setActiveSessionIdMapRaw(seeded.activeSessionIdMap);
|
|
if (seeded.panelViewChanged) {
|
|
setLatestAIPanelViewByScopeSnapshot(seeded.panelViewByScope);
|
|
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
|
setPanelViewByScopeRaw(seeded.panelViewByScope);
|
|
}
|
|
}, []);
|
|
|
|
const handoffDissolvedWorkspaceScope = useCallback((input: {
|
|
workspaceId: string;
|
|
terminalIds: readonly string[];
|
|
preferredTerminalId?: string | null;
|
|
}) => {
|
|
const currentMap =
|
|
latestAIActiveSessionMapSnapshot
|
|
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
|
?? {};
|
|
const currentSessions =
|
|
latestAISessionsSnapshot
|
|
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
|
?? [];
|
|
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
|
const result = handoffDissolvedWorkspaceAIScope({
|
|
activeSessionIdMap: currentMap,
|
|
sessions: currentSessions,
|
|
workspaceId: input.workspaceId,
|
|
terminalIds: input.terminalIds,
|
|
preferredTerminalId: input.preferredTerminalId,
|
|
panelViewByScope: currentPanelViewByScope,
|
|
});
|
|
if (!result.changed) return;
|
|
|
|
if (result.activeSessionIdMap !== currentMap) {
|
|
setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap);
|
|
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
|
setActiveSessionIdMapRaw(result.activeSessionIdMap);
|
|
}
|
|
|
|
if (result.sessions !== currentSessions) {
|
|
sessionsRef.current = result.sessions;
|
|
setLatestAISessionsSnapshot(result.sessions);
|
|
persistSessions(result.sessions);
|
|
setSessionsRaw(result.sessions);
|
|
}
|
|
|
|
if (result.panelViewByScope !== currentPanelViewByScope) {
|
|
setLatestAIPanelViewByScopeSnapshot(result.panelViewByScope);
|
|
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
|
setPanelViewByScopeRaw(result.panelViewByScope);
|
|
}
|
|
}, [persistSessions]);
|
|
|
|
const retargetWorkspaceActiveChatForMemberLoss = useCallback((input: {
|
|
workspaceId: string;
|
|
previousMemberTerminalIds: readonly string[];
|
|
currentMemberTerminalIds: readonly string[];
|
|
preferredTerminalId?: string | null;
|
|
}) => {
|
|
const currentMap =
|
|
latestAIActiveSessionMapSnapshot
|
|
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
|
?? {};
|
|
const currentSessions =
|
|
latestAISessionsSnapshot
|
|
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
|
?? [];
|
|
const result = retargetWorkspaceActiveChatAfterMemberLoss({
|
|
activeSessionIdMap: currentMap,
|
|
sessions: currentSessions,
|
|
workspaceId: input.workspaceId,
|
|
previousMemberTerminalIds: input.previousMemberTerminalIds,
|
|
currentMemberTerminalIds: input.currentMemberTerminalIds,
|
|
preferredTerminalId: input.preferredTerminalId,
|
|
});
|
|
if (!result.changed) return;
|
|
|
|
if (result.activeSessionIdMap !== currentMap) {
|
|
setLatestAIActiveSessionMapSnapshot(result.activeSessionIdMap);
|
|
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, result.activeSessionIdMap);
|
|
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
|
setActiveSessionIdMapRaw(result.activeSessionIdMap);
|
|
}
|
|
|
|
if (result.sessions !== currentSessions) {
|
|
sessionsRef.current = result.sessions;
|
|
setLatestAISessionsSnapshot(result.sessions);
|
|
persistSessions(result.sessions);
|
|
setSessionsRaw(result.sessions);
|
|
}
|
|
}, [persistSessions]);
|
|
|
|
// ── Provider CRUD helpers ──
|
|
const addProvider = useCallback((provider: ProviderConfig) => {
|
|
setProviders(prev => [...prev, provider]);
|
|
}, [setProviders]);
|
|
|
|
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
|
|
setProviders((prev) => {
|
|
const index = prev.findIndex((provider) => provider.id === id);
|
|
if (index < 0) return prev;
|
|
const current = prev[index];
|
|
if (providerPatchIsNoop(current, updates)) return prev;
|
|
const next = prev.slice();
|
|
next[index] = { ...current, ...updates };
|
|
return next;
|
|
});
|
|
}, [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;
|
|
|
|
// Stream/message updates publish here so AIConfig Context consumers do not
|
|
// re-render on every token. AIChatPanelsHost reads aiSessionsStore instead.
|
|
useLayoutEffect(() => {
|
|
publishAISessionsSnapshot({
|
|
sessions,
|
|
activeSessionIdMap,
|
|
draftsByScope,
|
|
panelViewByScope,
|
|
});
|
|
}, [sessions, activeSessionIdMap, draftsByScope, panelViewByScope]);
|
|
|
|
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,
|
|
responseIdleTimeout,
|
|
setResponseIdleTimeout,
|
|
maxIterations,
|
|
setMaxIterations,
|
|
agentModelMap,
|
|
setAgentModel,
|
|
agentProviderMap,
|
|
setAgentProvider,
|
|
agentThinkingMap,
|
|
setAgentThinking,
|
|
webSearchConfig,
|
|
setWebSearchConfig,
|
|
quickMessages,
|
|
setQuickMessages,
|
|
// sessions / activeSessionIdMap / draftsByScope / panelViewByScope live in
|
|
// aiSessionsStore so streaming updates do not rebuild this config object.
|
|
setActiveSessionId,
|
|
ensureDraftForScope,
|
|
updateDraft,
|
|
showDraftView,
|
|
showSessionView,
|
|
clearDraftForScope,
|
|
addDraftFiles,
|
|
removeDraftFile,
|
|
createSession,
|
|
deleteSession,
|
|
deleteSessionsByTarget,
|
|
updateSessionTitle,
|
|
updateSessionExternalSessionId,
|
|
addMessageToSession,
|
|
updateLastMessage,
|
|
updateMessageById,
|
|
persistContextCompaction,
|
|
cleanupOrphanedSessions,
|
|
seedWorkspaceActiveSessionFromMembers,
|
|
handoffDissolvedWorkspaceScope,
|
|
retargetWorkspaceActiveChatForMemberLoss,
|
|
}), [
|
|
providers,
|
|
setProviders,
|
|
addProvider,
|
|
updateProvider,
|
|
removeProvider,
|
|
activeProviderId,
|
|
setActiveProviderId,
|
|
activeModelId,
|
|
setActiveModelId,
|
|
activeProvider,
|
|
globalPermissionMode,
|
|
setGlobalPermissionMode,
|
|
toolIntegrationMode,
|
|
setToolIntegrationMode,
|
|
hostPermissions,
|
|
setHostPermissions,
|
|
externalAgents,
|
|
setExternalAgents,
|
|
defaultAgentId,
|
|
setDefaultAgentId,
|
|
commandBlocklist,
|
|
setCommandBlocklist,
|
|
commandTimeout,
|
|
setCommandTimeout,
|
|
responseIdleTimeout,
|
|
setResponseIdleTimeout,
|
|
maxIterations,
|
|
setMaxIterations,
|
|
agentModelMap,
|
|
setAgentModel,
|
|
agentProviderMap,
|
|
setAgentProvider,
|
|
agentThinkingMap,
|
|
setAgentThinking,
|
|
webSearchConfig,
|
|
setWebSearchConfig,
|
|
quickMessages,
|
|
setQuickMessages,
|
|
setActiveSessionId,
|
|
ensureDraftForScope,
|
|
updateDraft,
|
|
showDraftView,
|
|
showSessionView,
|
|
clearDraftForScope,
|
|
addDraftFiles,
|
|
removeDraftFile,
|
|
createSession,
|
|
deleteSession,
|
|
deleteSessionsByTarget,
|
|
updateSessionTitle,
|
|
updateSessionExternalSessionId,
|
|
addMessageToSession,
|
|
updateLastMessage,
|
|
updateMessageById,
|
|
persistContextCompaction,
|
|
cleanupOrphanedSessions,
|
|
seedWorkspaceActiveSessionFromMembers,
|
|
handoffDissolvedWorkspaceScope,
|
|
retargetWorkspaceActiveChatForMemberLoss,
|
|
]);
|
|
}
|