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
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { buildAITerminalSessionInfo } from '../../domain/buildAITerminalSessionInfo';
|
|
import { detectLocalOs } from '../../lib/localShell';
|
|
import type { Host, PortForwardingRule, TerminalSession } from '../../types';
|
|
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
|
|
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
|
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
|
|
import { readExternalMcpStoredEnabled } from './useExternalMcpToggleState';
|
|
|
|
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
|
|
|
|
type UseExternalMcpSessionSyncOptions = {
|
|
sessions: TerminalSession[];
|
|
sessionHostsMap?: Map<string, Host>;
|
|
hosts: Host[];
|
|
portForwardingRules: PortForwardingRule[];
|
|
};
|
|
|
|
export function createLatestPayloadSync<T>(
|
|
send: (payload: T) => Promise<unknown>,
|
|
) {
|
|
let desired: { serialized: string; payload: T } | null = null;
|
|
let acknowledged = '';
|
|
let inFlight: Promise<void> | null = null;
|
|
let cancelled = false;
|
|
|
|
const pump = async () => {
|
|
while (!cancelled && desired && desired.serialized !== acknowledged) {
|
|
const target = desired;
|
|
try {
|
|
const result = await send(target.payload);
|
|
if (cancelled) break;
|
|
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
|
if (desired === target) break;
|
|
continue;
|
|
}
|
|
acknowledged = target.serialized;
|
|
} catch {
|
|
if (desired === target) break;
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
|
|
if (cancelled) return Promise.resolve();
|
|
desired = { payload, serialized };
|
|
if (!inFlight) {
|
|
inFlight = pump().finally(() => {
|
|
inFlight = null;
|
|
});
|
|
}
|
|
return inFlight;
|
|
},
|
|
cancel(): void {
|
|
cancelled = true;
|
|
desired = null;
|
|
},
|
|
};
|
|
}
|
|
|
|
function isMainAppWindow(): boolean {
|
|
if (typeof window === 'undefined') return false;
|
|
const hash = window.location.hash || '';
|
|
// Peer session windows and settings-only windows must not own the
|
|
// app-wide External MCP scope — they only see a partial session set.
|
|
if (hash.startsWith('#/session-window')) return false;
|
|
if (hash.startsWith('#/settings')) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Keep the reserved External MCP scope aligned with every live terminal
|
|
* session, independent of whether the Catty AI side panel / TerminalLayer
|
|
* has mounted yet.
|
|
*/
|
|
export function useExternalMcpSessionSync({
|
|
sessions,
|
|
sessionHostsMap,
|
|
hosts,
|
|
portForwardingRules,
|
|
}: UseExternalMcpSessionSyncOptions) {
|
|
const [enabledTick, setEnabledTick] = useState(0);
|
|
const enabled = useMemo(() => {
|
|
void enabledTick;
|
|
return readExternalMcpStoredEnabled();
|
|
}, [enabledTick]);
|
|
|
|
useEffect(() => {
|
|
const bump = () => setEnabledTick((value) => value + 1);
|
|
const handleStorage = (event: StorageEvent) => {
|
|
if (event.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
|
bump();
|
|
};
|
|
const handleLocalStateChanged = (event: Event) => {
|
|
const detail = (event as CustomEvent<{ key?: string }>).detail;
|
|
if (detail?.key && detail.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
|
bump();
|
|
};
|
|
window.addEventListener('storage', handleStorage);
|
|
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged as EventListener);
|
|
return () => {
|
|
window.removeEventListener('storage', handleStorage);
|
|
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged as EventListener);
|
|
};
|
|
}, []);
|
|
|
|
const payload = useMemo(() => {
|
|
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
|
|
const hostById = new Map(hosts.map((host) => [host.id, host]));
|
|
return sessions.map((session) => {
|
|
const host = sessionHostsMap?.get(session.id)
|
|
|| (session.hostId ? hostById.get(session.hostId) : undefined);
|
|
return buildAITerminalSessionInfo(session, host, localOs, {
|
|
allHosts: hosts,
|
|
portForwardingRules,
|
|
});
|
|
});
|
|
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
|
|
|
|
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
|
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!isMainAppWindow()) return;
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.aiMcpUpdateLiveSessions) return;
|
|
const serialized = JSON.stringify(payload);
|
|
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
|
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
|
|
));
|
|
void liveSyncRef.current.push(payload, serialized);
|
|
}, [payload]);
|
|
|
|
useEffect(() => {
|
|
if (!isMainAppWindow()) return;
|
|
if (!enabled) {
|
|
// Recreate the synchronizer after re-enabling because disabling can
|
|
// clear the main-process scope even when the renderer payload is equal.
|
|
externalSyncRef.current?.cancel();
|
|
externalSyncRef.current = null;
|
|
return;
|
|
}
|
|
const bridge = netcattyBridge.get();
|
|
if (!bridge?.aiMcpUpdateSessions) return;
|
|
|
|
const serialized = JSON.stringify(payload);
|
|
const timeoutId = window.setTimeout(() => {
|
|
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
|
Promise.resolve(bridge.aiMcpUpdateSessions?.(
|
|
nextPayload,
|
|
EXTERNAL_MCP_CHAT_SESSION_ID,
|
|
))
|
|
));
|
|
void externalSyncRef.current.push(payload, serialized);
|
|
}, 250);
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
}, [enabled, payload]);
|
|
}
|