hangwin--mcp-chrome
538 行
17 KiB
TypeScript
538 行
17 KiB
TypeScript
/**
|
|
* Composable for managing Agent Sessions.
|
|
* Sessions represent independent conversations within a project.
|
|
* Each session has its own engine configuration, chat history, and resume state.
|
|
*/
|
|
import { ref, computed, watch } from 'vue';
|
|
import type {
|
|
AgentSession,
|
|
AgentCliPreference,
|
|
CreateAgentSessionInput,
|
|
UpdateAgentSessionInput,
|
|
AgentStoredMessage,
|
|
AgentManagementInfo,
|
|
} from 'chrome-mcp-shared';
|
|
|
|
const STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id';
|
|
|
|
export interface UseAgentSessionsOptions {
|
|
getServerPort: () => number | null;
|
|
ensureServer: () => Promise<boolean>;
|
|
onSessionChanged?: (sessionId: string) => void;
|
|
onHistoryLoaded?: (messages: AgentStoredMessage[]) => void;
|
|
}
|
|
|
|
export function useAgentSessions(options: UseAgentSessionsOptions) {
|
|
// State
|
|
const sessions = ref<AgentSession[]>([]);
|
|
const allSessions = ref<AgentSession[]>([]); // All sessions across all projects
|
|
const selectedSessionId = ref<string>('');
|
|
const isLoadingSessions = ref(false);
|
|
const isLoadingAllSessions = ref(false);
|
|
const isCreatingSession = ref(false);
|
|
const sessionError = ref<string | null>(null);
|
|
|
|
// Computed
|
|
const selectedSession = computed(() => {
|
|
return sessions.value.find((s) => s.id === selectedSessionId.value) || null;
|
|
});
|
|
|
|
const hasSessions = computed(() => sessions.value.length > 0);
|
|
|
|
// Load selected session from storage
|
|
async function loadSelectedSessionId(): Promise<void> {
|
|
try {
|
|
const result = await chrome.storage.local.get(STORAGE_KEY_SELECTED_SESSION);
|
|
if (result[STORAGE_KEY_SELECTED_SESSION]) {
|
|
selectedSessionId.value = result[STORAGE_KEY_SELECTED_SESSION];
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load selected session ID:', error);
|
|
}
|
|
}
|
|
|
|
// Save selected session to storage
|
|
async function saveSelectedSessionId(): Promise<void> {
|
|
try {
|
|
await chrome.storage.local.set({
|
|
[STORAGE_KEY_SELECTED_SESSION]: selectedSessionId.value,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to save selected session ID:', error);
|
|
}
|
|
}
|
|
|
|
// Track pending session fetch with nonce to prevent A→B→A race conditions
|
|
let fetchSessionsNonce = 0;
|
|
|
|
/**
|
|
* Fetch sessions for a project with race-condition protection.
|
|
* Uses a nonce to handle A→B→A scenarios.
|
|
*/
|
|
async function fetchSessions(projectId: string): Promise<void> {
|
|
const serverPort = options.getServerPort();
|
|
if (!serverPort || !projectId) return;
|
|
|
|
// Increment nonce - any subsequent fetch will invalidate this one
|
|
const myNonce = ++fetchSessionsNonce;
|
|
|
|
const isStillValid = (): boolean => {
|
|
return myNonce === fetchSessionsNonce;
|
|
};
|
|
|
|
isLoadingSessions.value = true;
|
|
sessionError.value = null;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/projects/${encodeURIComponent(projectId)}/sessions`;
|
|
const response = await fetch(url);
|
|
|
|
if (!isStillValid()) return;
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
if (!isStillValid()) return;
|
|
|
|
sessions.value = data.sessions || [];
|
|
|
|
// If we have sessions but no selection, select the most recent one
|
|
if (sessions.value.length > 0 && !selectedSessionId.value) {
|
|
selectedSessionId.value = sessions.value[0].id;
|
|
await saveSelectedSessionId();
|
|
}
|
|
} else {
|
|
const text = await response.text().catch(() => '');
|
|
sessionError.value = text || `HTTP ${response.status}`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch sessions:', error);
|
|
sessionError.value = error instanceof Error ? error.message : 'Failed to fetch sessions';
|
|
} finally {
|
|
isLoadingSessions.value = false;
|
|
}
|
|
}
|
|
|
|
// Track pending all sessions fetch with nonce
|
|
let fetchAllSessionsNonce = 0;
|
|
|
|
/**
|
|
* Fetch all sessions across all projects.
|
|
* Used for the global sessions list view.
|
|
*/
|
|
async function fetchAllSessions(): Promise<void> {
|
|
const serverPort = options.getServerPort();
|
|
if (!serverPort) return;
|
|
|
|
const myNonce = ++fetchAllSessionsNonce;
|
|
|
|
const isStillValid = (): boolean => {
|
|
return myNonce === fetchAllSessionsNonce;
|
|
};
|
|
|
|
isLoadingAllSessions.value = true;
|
|
sessionError.value = null;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/sessions`;
|
|
const response = await fetch(url);
|
|
|
|
if (!isStillValid()) return;
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
if (!isStillValid()) return;
|
|
|
|
allSessions.value = data.sessions || [];
|
|
} else {
|
|
const text = await response.text().catch(() => '');
|
|
sessionError.value = text || `HTTP ${response.status}`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch all sessions:', error);
|
|
sessionError.value = error instanceof Error ? error.message : 'Failed to fetch sessions';
|
|
} finally {
|
|
isLoadingAllSessions.value = false;
|
|
}
|
|
}
|
|
|
|
// Track pending create session with nonce to prevent cross-project pollution
|
|
let createSessionNonce = 0;
|
|
|
|
/**
|
|
* Create a new session with race-condition protection.
|
|
* Uses a nonce to prevent cross-project state pollution when user switches
|
|
* projects during session creation.
|
|
*/
|
|
async function createSession(
|
|
projectId: string,
|
|
input: CreateAgentSessionInput,
|
|
): Promise<AgentSession | null> {
|
|
const ready = await options.ensureServer();
|
|
const serverPort = options.getServerPort();
|
|
if (!ready || !serverPort) {
|
|
sessionError.value = 'Server not available';
|
|
return null;
|
|
}
|
|
|
|
// Increment nonce - any subsequent create will invalidate this one
|
|
const myNonce = ++createSessionNonce;
|
|
|
|
isCreatingSession.value = true;
|
|
sessionError.value = null;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/projects/${encodeURIComponent(projectId)}/sessions`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(input),
|
|
});
|
|
|
|
// Guard: check if this is still the expected create operation
|
|
if (myNonce !== createSessionNonce) {
|
|
// A newer create was initiated - discard this result
|
|
return null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(text || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Re-check after json parsing
|
|
if (myNonce !== createSessionNonce) {
|
|
return null;
|
|
}
|
|
|
|
const session = data.session as AgentSession | undefined;
|
|
|
|
if (session?.id) {
|
|
// Add to local list and select it
|
|
sessions.value = [session, ...sessions.value];
|
|
// Also add to allSessions (at front, as it's the newest)
|
|
allSessions.value = [session, ...allSessions.value.filter((s) => s.id !== session.id)];
|
|
selectedSessionId.value = session.id;
|
|
await saveSelectedSessionId();
|
|
options.onSessionChanged?.(session.id);
|
|
return session;
|
|
}
|
|
|
|
sessionError.value = 'Session created but response is invalid';
|
|
return null;
|
|
} catch (error) {
|
|
// Guard: only handle error if still valid
|
|
if (myNonce !== createSessionNonce) {
|
|
return null;
|
|
}
|
|
console.error('Failed to create session:', error);
|
|
sessionError.value = error instanceof Error ? error.message : 'Failed to create session';
|
|
return null;
|
|
} finally {
|
|
isCreatingSession.value = false;
|
|
}
|
|
}
|
|
|
|
// Get a session by ID
|
|
async function getSession(sessionId: string): Promise<AgentSession | null> {
|
|
const serverPort = options.getServerPort();
|
|
if (!serverPort || !sessionId) return null;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`;
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
return data.session || null;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Failed to get session:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Update a session
|
|
async function updateSession(
|
|
sessionId: string,
|
|
updates: UpdateAgentSessionInput,
|
|
): Promise<AgentSession | null> {
|
|
const serverPort = options.getServerPort();
|
|
if (!serverPort || !sessionId) return null;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`;
|
|
const response = await fetch(url, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(text || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const session = data.session as AgentSession | undefined;
|
|
|
|
if (session?.id) {
|
|
// Update local list
|
|
const index = sessions.value.findIndex((s) => s.id === session.id);
|
|
if (index !== -1) {
|
|
sessions.value[index] = session;
|
|
}
|
|
// Also update allSessions (in-place to preserve order)
|
|
const allIndex = allSessions.value.findIndex((s) => s.id === session.id);
|
|
if (allIndex !== -1) {
|
|
allSessions.value[allIndex] = session;
|
|
}
|
|
return session;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Failed to update session:', error);
|
|
sessionError.value = error instanceof Error ? error.message : 'Failed to update session';
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Delete a session
|
|
async function deleteSession(sessionId: string): Promise<boolean> {
|
|
const serverPort = options.getServerPort();
|
|
if (!serverPort || !sessionId) return false;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`;
|
|
const response = await fetch(url, { method: 'DELETE' });
|
|
|
|
if (response.ok || response.status === 204) {
|
|
// Remove from local list
|
|
sessions.value = sessions.value.filter((s) => s.id !== sessionId);
|
|
// Also remove from allSessions
|
|
allSessions.value = allSessions.value.filter((s) => s.id !== sessionId);
|
|
|
|
// If deleted session was selected, select another one
|
|
if (selectedSessionId.value === sessionId) {
|
|
selectedSessionId.value = sessions.value[0]?.id || '';
|
|
await saveSelectedSessionId();
|
|
if (selectedSessionId.value) {
|
|
options.onSessionChanged?.(selectedSessionId.value);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} catch (error) {
|
|
console.error('Failed to delete session:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Select a session
|
|
async function selectSession(sessionId: string): Promise<void> {
|
|
if (selectedSessionId.value === sessionId) return;
|
|
|
|
selectedSessionId.value = sessionId;
|
|
await saveSelectedSessionId();
|
|
options.onSessionChanged?.(sessionId);
|
|
}
|
|
|
|
// Create a default session for a project if none exist
|
|
async function ensureDefaultSession(
|
|
projectId: string,
|
|
engineName: AgentCliPreference = 'claude',
|
|
): Promise<AgentSession | null> {
|
|
await fetchSessions(projectId);
|
|
|
|
// If sessions exist, select the first one if none selected
|
|
if (sessions.value.length > 0) {
|
|
if (
|
|
!selectedSessionId.value ||
|
|
!sessions.value.find((s) => s.id === selectedSessionId.value)
|
|
) {
|
|
await selectSession(sessions.value[0].id);
|
|
}
|
|
return selectedSession.value;
|
|
}
|
|
|
|
// Create default session
|
|
return createSession(projectId, {
|
|
engineName,
|
|
name: 'Default Session',
|
|
});
|
|
}
|
|
|
|
// Rename a session
|
|
async function renameSession(sessionId: string, name: string): Promise<boolean> {
|
|
const result = await updateSession(sessionId, { name });
|
|
return result !== null;
|
|
}
|
|
|
|
// Reset a session conversation (delete messages + clear engineSessionId)
|
|
async function resetConversation(sessionId: string): Promise<{
|
|
deletedMessages: number;
|
|
clearedEngineSessionId: boolean;
|
|
session: AgentSession | null;
|
|
} | null> {
|
|
const ready = await options.ensureServer();
|
|
const serverPort = options.getServerPort();
|
|
if (!ready || !serverPort || !sessionId) {
|
|
sessionError.value = 'Server not available';
|
|
return null;
|
|
}
|
|
|
|
sessionError.value = null;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}/reset`;
|
|
const response = await fetch(url, { method: 'POST' });
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(text || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const session = data.session as AgentSession | null;
|
|
|
|
// Update local session state
|
|
if (session?.id) {
|
|
const index = sessions.value.findIndex((s) => s.id === session.id);
|
|
if (index !== -1) {
|
|
sessions.value[index] = session;
|
|
}
|
|
}
|
|
|
|
return {
|
|
deletedMessages: typeof data.deletedMessages === 'number' ? data.deletedMessages : 0,
|
|
clearedEngineSessionId: data.clearedEngineSessionId === true,
|
|
session,
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to reset conversation:', error);
|
|
sessionError.value = error instanceof Error ? error.message : 'Failed to reset conversation';
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Fetch Claude SDK management info for a session
|
|
async function fetchClaudeInfo(sessionId: string): Promise<{
|
|
managementInfo: AgentManagementInfo | null;
|
|
sessionId: string;
|
|
engineName: string;
|
|
} | null> {
|
|
const serverPort = options.getServerPort();
|
|
if (!serverPort || !sessionId) return null;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}/claude-info`;
|
|
const response = await fetch(url);
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(text || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return {
|
|
managementInfo: data.managementInfo ?? null,
|
|
sessionId: data.sessionId ?? sessionId,
|
|
engineName: data.engineName ?? '',
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to fetch Claude info:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Clear sessions when project changes
|
|
function clearSessions(): void {
|
|
sessions.value = [];
|
|
selectedSessionId.value = '';
|
|
}
|
|
|
|
/**
|
|
* Update session preview and updatedAt locally (without server call).
|
|
* Used when sending a message to update the display immediately.
|
|
* Always updates updatedAt so the session moves to the top of the list.
|
|
* @param sessionId - The session to update
|
|
* @param preview - The preview text (user's raw input)
|
|
* @param previewMeta - Optional structured metadata for special rendering (e.g., web editor apply chip)
|
|
*/
|
|
function updateSessionPreview(
|
|
sessionId: string,
|
|
preview: string,
|
|
previewMeta?: AgentSession['previewMeta'],
|
|
): void {
|
|
// Truncate to 50 chars with ellipsis
|
|
const maxLen = 50;
|
|
const trimmed = preview.trim().replace(/\s+/g, ' ');
|
|
const truncated = trimmed.length > maxLen ? trimmed.slice(0, maxLen - 1) + '…' : trimmed;
|
|
|
|
// Always update updatedAt to move session to top of list
|
|
const now = new Date().toISOString();
|
|
|
|
// Update in current project sessions
|
|
const index = sessions.value.findIndex((s) => s.id === sessionId);
|
|
if (index !== -1) {
|
|
sessions.value[index] = {
|
|
...sessions.value[index],
|
|
// Only update preview if not already set
|
|
preview: sessions.value[index].preview || truncated,
|
|
previewMeta: sessions.value[index].previewMeta || previewMeta,
|
|
// Always update timestamp so session moves to top
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
// Also update in allSessions for global list view
|
|
const allIndex = allSessions.value.findIndex((s) => s.id === sessionId);
|
|
if (allIndex !== -1) {
|
|
allSessions.value[allIndex] = {
|
|
...allSessions.value[allIndex],
|
|
preview: allSessions.value[allIndex].preview || truncated,
|
|
previewMeta: allSessions.value[allIndex].previewMeta || previewMeta,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
// State
|
|
sessions,
|
|
allSessions,
|
|
selectedSessionId,
|
|
isLoadingSessions,
|
|
isLoadingAllSessions,
|
|
isCreatingSession,
|
|
sessionError,
|
|
|
|
// Computed
|
|
selectedSession,
|
|
hasSessions,
|
|
|
|
// Methods
|
|
loadSelectedSessionId,
|
|
saveSelectedSessionId,
|
|
fetchSessions,
|
|
fetchAllSessions,
|
|
createSession,
|
|
getSession,
|
|
updateSession,
|
|
deleteSession,
|
|
selectSession,
|
|
ensureDefaultSession,
|
|
renameSession,
|
|
resetConversation,
|
|
fetchClaudeInfo,
|
|
clearSessions,
|
|
updateSessionPreview,
|
|
};
|
|
}
|