generated from nhcarrigan/template
feat: add ability to run multiple agents via tabbed views (#47)
### Explanation _No response_ ### Issue Closes #30 Closes #41 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #47 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #47.
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import type { TerminalLine, ConnectionStatus, PermissionRequest } 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>;
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
}
|
||||
|
||||
function createConversationsStore() {
|
||||
const conversations = writable<Map<string, Conversation>>(new Map());
|
||||
const activeConversationId = writable<string | null>(null);
|
||||
const pendingPermission = writable<PermissionRequest | 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(),
|
||||
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>()
|
||||
);
|
||||
|
||||
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 },
|
||||
isProcessing: { subscribe: isProcessing.subscribe },
|
||||
grantedTools: { subscribe: grantedTools.subscribe },
|
||||
pendingRetryMessage: { subscribe: pendingRetryMessage.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) => pendingPermission.set(request),
|
||||
clearPermission: () => pendingPermission.set(null),
|
||||
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;
|
||||
});
|
||||
},
|
||||
|
||||
// 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();
|
||||
Reference in New Issue
Block a user