[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
163
application/state/useExternalMcpSessionSync.ts
Normal file
163
application/state/useExternalMcpSessionSync.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user