generated from nhcarrigan/template
c088dc0096
- Added scrollPosition to Conversation interface - Save scroll position when switching away from a tab - Restore exact scroll position when switching back - Uses -1 to indicate auto-scroll mode (scroll to bottom) - Prevents interference between scroll restore and auto-scroll
584 lines
17 KiB
TypeScript
584 lines
17 KiB
TypeScript
import { writable, derived, get } from "svelte/store";
|
|
import type {
|
|
TerminalLine,
|
|
ConnectionStatus,
|
|
PermissionRequest,
|
|
UserQuestionEvent,
|
|
} from "$lib/types/messages";
|
|
import type { CharacterState } from "$lib/types/states";
|
|
import { cleanupConversationTracking } from "$lib/tauri";
|
|
import { characterState } from "$lib/stores/character";
|
|
|
|
export interface Conversation {
|
|
id: string;
|
|
name: string;
|
|
terminalLines: TerminalLine[];
|
|
sessionId: string | null;
|
|
connectionStatus: ConnectionStatus;
|
|
workingDirectory: string;
|
|
characterState: CharacterState;
|
|
isProcessing: boolean;
|
|
grantedTools: Set<string>;
|
|
pendingPermission: PermissionRequest | null;
|
|
pendingQuestion: UserQuestionEvent | null;
|
|
scrollPosition: number;
|
|
createdAt: Date;
|
|
lastActivityAt: Date;
|
|
}
|
|
|
|
function createConversationsStore() {
|
|
const conversations = writable<Map<string, Conversation>>(new Map());
|
|
const activeConversationId = writable<string | null>(null);
|
|
const pendingRetryMessage = writable<string | null>(null);
|
|
|
|
let conversationCounter = 0;
|
|
let lineIdCounter = 0;
|
|
|
|
function generateConversationId(): string {
|
|
return `conv-${Date.now()}-${conversationCounter++}`;
|
|
}
|
|
|
|
function generateLineId(): string {
|
|
return `line-${Date.now()}-${lineIdCounter++}`;
|
|
}
|
|
|
|
function createNewConversation(name?: string): Conversation {
|
|
const id = generateConversationId();
|
|
return {
|
|
id,
|
|
name: name || `Conversation ${conversationCounter}`,
|
|
terminalLines: [],
|
|
sessionId: null,
|
|
connectionStatus: "disconnected",
|
|
workingDirectory: "",
|
|
characterState: "idle",
|
|
isProcessing: false,
|
|
grantedTools: new Set(),
|
|
pendingPermission: null,
|
|
pendingQuestion: null,
|
|
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
|
createdAt: new Date(),
|
|
lastActivityAt: new Date(),
|
|
};
|
|
}
|
|
|
|
// Initialize with first conversation lazily
|
|
let initialized = false;
|
|
function ensureInitialized() {
|
|
if (!initialized) {
|
|
initialized = true;
|
|
const initialConversation = createNewConversation("Main");
|
|
conversations.update((convs) => {
|
|
convs.set(initialConversation.id, initialConversation);
|
|
return convs;
|
|
});
|
|
activeConversationId.set(initialConversation.id);
|
|
}
|
|
}
|
|
|
|
// Derived store for current conversation
|
|
const activeConversation = derived(
|
|
[conversations, activeConversationId],
|
|
([$conversations, $activeId]) => {
|
|
if (!$activeId) return null;
|
|
return $conversations.get($activeId) || null;
|
|
}
|
|
);
|
|
|
|
// Derived stores for compatibility with existing code
|
|
const connectionStatus = derived(
|
|
activeConversation,
|
|
($conv) => $conv?.connectionStatus || "disconnected"
|
|
);
|
|
const terminalLines = derived(activeConversation, ($conv) => {
|
|
return $conv?.terminalLines || [];
|
|
});
|
|
const sessionId = derived(activeConversation, ($conv) => $conv?.sessionId || null);
|
|
const currentWorkingDirectory = derived(
|
|
activeConversation,
|
|
($conv) => $conv?.workingDirectory || ""
|
|
);
|
|
const isProcessing = derived(activeConversation, ($conv) => $conv?.isProcessing || false);
|
|
const grantedTools = derived(
|
|
activeConversation,
|
|
($conv) => $conv?.grantedTools || new Set<string>()
|
|
);
|
|
const pendingPermission = derived(
|
|
activeConversation,
|
|
($conv) => $conv?.pendingPermission || null
|
|
);
|
|
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
|
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
|
|
|
return {
|
|
// Expose derived stores for compatibility
|
|
connectionStatus: { subscribe: connectionStatus.subscribe },
|
|
sessionId: { subscribe: sessionId.subscribe },
|
|
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
|
|
terminalLines: { subscribe: terminalLines.subscribe },
|
|
pendingPermission: { subscribe: pendingPermission.subscribe },
|
|
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
|
isProcessing: { subscribe: isProcessing.subscribe },
|
|
grantedTools: { subscribe: grantedTools.subscribe },
|
|
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
|
scrollPosition: { subscribe: scrollPosition.subscribe },
|
|
|
|
// New conversation-specific stores
|
|
conversations: { subscribe: conversations.subscribe },
|
|
activeConversationId: { subscribe: activeConversationId.subscribe },
|
|
activeConversation: { subscribe: activeConversation.subscribe },
|
|
|
|
// Connection management
|
|
setConnectionStatus: (status: ConnectionStatus) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.connectionStatus = status;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
setConnectionStatusForConversation: (conversationId: string, status: ConnectionStatus) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.connectionStatus = status;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
setCharacterStateForConversation: (conversationId: string, state: CharacterState) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.characterState = state;
|
|
// If this is the active conversation, update the global character state
|
|
if (conversationId === get(activeConversationId)) {
|
|
characterState.setState(state);
|
|
}
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
requestPermission: (request: PermissionRequest) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.pendingPermission = request;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
clearPermission: () => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.pendingPermission = null;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
requestPermissionForConversation: (conversationId: string, request: PermissionRequest) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.pendingPermission = request;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
clearPermissionForConversation: (conversationId: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.pendingPermission = null;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
requestQuestion: (question: UserQuestionEvent) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.pendingQuestion = question;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
clearQuestion: () => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.pendingQuestion = null;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
requestQuestionForConversation: (conversationId: string, question: UserQuestionEvent) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.pendingQuestion = question;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
clearQuestionForConversation: (conversationId: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.pendingQuestion = null;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
|
|
|
// Conversation management
|
|
createConversation: (name?: string) => {
|
|
ensureInitialized();
|
|
const newConv = createNewConversation(name);
|
|
conversations.update((convs) => {
|
|
convs.set(newConv.id, newConv);
|
|
return convs;
|
|
});
|
|
activeConversationId.set(newConv.id);
|
|
return newConv.id;
|
|
},
|
|
|
|
deleteConversation: (id: string) => {
|
|
ensureInitialized();
|
|
const convs = get(conversations);
|
|
const activeId = get(activeConversationId);
|
|
|
|
if (convs.size <= 1) {
|
|
// Don't delete the last conversation
|
|
return false;
|
|
}
|
|
|
|
// Clean up tracking for this conversation
|
|
cleanupConversationTracking(id);
|
|
|
|
conversations.update((c) => {
|
|
c.delete(id);
|
|
return c;
|
|
});
|
|
|
|
// If we deleted the active conversation, switch to another
|
|
if (activeId === id) {
|
|
const remaining = Array.from(get(conversations).keys());
|
|
if (remaining.length > 0) {
|
|
activeConversationId.set(remaining[0]);
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
|
|
switchConversation: async (id: string) => {
|
|
const convs = get(conversations);
|
|
if (!convs.has(id)) return;
|
|
|
|
const currentId = get(activeConversationId);
|
|
const targetConv = convs.get(id);
|
|
|
|
// If switching to a different conversation
|
|
if (currentId !== id) {
|
|
activeConversationId.set(id);
|
|
|
|
// Update the global character state to match the conversation's state
|
|
if (targetConv) {
|
|
characterState.setState(targetConv.characterState);
|
|
}
|
|
}
|
|
},
|
|
|
|
renameConversation: (id: string, newName: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(id);
|
|
if (conv) {
|
|
conv.name = newName;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
saveScrollPosition: (id: string, position: number) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(id);
|
|
if (conv) {
|
|
conv.scrollPosition = position;
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
getScrollPosition: (id: string): number => {
|
|
const convs = get(conversations);
|
|
const conv = convs.get(id);
|
|
return conv?.scrollPosition ?? -1;
|
|
},
|
|
|
|
// Methods that operate on the active conversation
|
|
setSessionId: (id: string | null) => {
|
|
ensureInitialized();
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.sessionId = id;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
setSessionIdForConversation: (conversationId: string, id: string | null) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.sessionId = id;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
setWorkingDirectory: (dir: string) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.workingDirectory = dir;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
setWorkingDirectoryForConversation: (conversationId: string, dir: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.workingDirectory = dir;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
setProcessing: (processing: boolean) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.isProcessing = processing;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
|
ensureInitialized();
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return "";
|
|
|
|
const line: TerminalLine = {
|
|
id: generateLineId(),
|
|
type,
|
|
content,
|
|
timestamp: new Date(),
|
|
toolName,
|
|
};
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.terminalLines.push(line);
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
|
|
return line.id;
|
|
},
|
|
|
|
addLineToConversation: (
|
|
conversationId: string,
|
|
type: TerminalLine["type"],
|
|
content: string,
|
|
toolName?: string
|
|
) => {
|
|
ensureInitialized();
|
|
|
|
const line: TerminalLine = {
|
|
id: generateLineId(),
|
|
type,
|
|
content,
|
|
timestamp: new Date(),
|
|
toolName,
|
|
};
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.terminalLines.push(line);
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
|
|
return line.id;
|
|
},
|
|
|
|
updateLine: (id: string, content: string) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
const line = conv.terminalLines.find((l) => l.id === id);
|
|
if (line) {
|
|
line.content = content;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
appendToLine: (id: string, additionalContent: string) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
const line = conv.terminalLines.find((l) => l.id === id);
|
|
if (line) {
|
|
line.content += additionalContent;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
clearTerminal: () => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.terminalLines = [];
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
getConversationHistory: (): string => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return "";
|
|
|
|
const convs = get(conversations);
|
|
const conv = convs.get(activeId);
|
|
if (!conv) return "";
|
|
|
|
const relevantLines = conv.terminalLines.filter(
|
|
(line) => line.type === "user" || line.type === "assistant"
|
|
);
|
|
|
|
if (relevantLines.length === 0) return "";
|
|
|
|
return relevantLines
|
|
.map((line) => {
|
|
const role = line.type === "user" ? "User" : "Assistant";
|
|
return `${role}: ${line.content}`;
|
|
})
|
|
.join("\n\n");
|
|
},
|
|
|
|
grantTool: (toolName: string) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.grantedTools.add(toolName);
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
revokeAllTools: () => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.grantedTools.clear();
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
isToolGranted: (toolName: string): boolean => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return false;
|
|
|
|
const convs = get(conversations);
|
|
const conv = convs.get(activeId);
|
|
return conv?.grantedTools.has(toolName) || false;
|
|
},
|
|
|
|
// Add initialization helper
|
|
initialize: () => {
|
|
ensureInitialized();
|
|
},
|
|
};
|
|
}
|
|
|
|
export const conversationsStore = createConversationsStore();
|
|
// Initialize immediately
|
|
conversationsStore.initialize();
|