generated from nhcarrigan/template
f173892aaa
## Summary This PR includes major feature additions, bug fixes, comprehensive testing improvements, and responsive design enhancements! ## New Features โจ ### Plugin & MCP Management (#133, #134) - **Plugin Management Panel**: Install, uninstall, enable/disable, and update plugins - **MCP Server Management Panel**: Add/remove MCP servers, view detailed configuration - **Marketplace Management**: Add/remove plugin marketplaces from GitHub - Backend commands for full CLI integration (`list_plugins`, `install_plugin`, `add_mcp_server`, etc.) - Beautiful UI with proper loading states, error handling, and theme support ### Visual Todo List Panel (#132) - Real-time todo list display when Hikari uses the `TodoWrite` tool - Shows pending/in-progress/completed status with visual indicators - Progress bar and completion count - Automatically clears on disconnect - Theme-aware styling ### Clear Session History Button (#130) - "Clear All Sessions" button in Session History panel - Confirmation dialog with session count - Keyboard support and accessibility features - Gives users control over disk usage ### CLI Version Display (#131) - Displays Claude CLI version in status bar - Auto-polls every 30 seconds for updates - Useful for debugging and feature compatibility ## Bug Fixes ๐ ### Stats Panel Scrolling (#136) - **Fixed stats panel overflow**: Added scrollable container with `max-height` constraint - Stats panel now scrolls when content (Tools Used, Historical Costs, Budget sections) gets too long - Prevents content from overflowing off screen ### Agent Monitor Fixes (#122) - **Fixed agents stuck in "running" state**: Added `SubagentStop` hook parsing - **Fixed agents persisting after disconnect**: Call `clearConversation()` on disconnect - **Fixed "Kill All" button**: Now properly marks all agents as errored - **Fixed badge persisting after tab close**: Cleanup agents when conversation is deleted - Comprehensive tests for agent lifecycle management ### Discord RPC Cleanup (#129) - Removed file-based logging for Discord RPC - Replaced with proper `tracing` framework usage - Reduces disk usage and eliminates maintenance burden ### Close Modal Bug Fix (#128) - Fixed close confirmation modal not triggering after Discord RPC refactor - Removed frontend calls to deleted `log_discord_rpc` command - Modal now works correctly after all operations ### Responsive Design Fixes (#118) - Fixed top navigation icons getting cut off at small screen widths - Fixed Connect button disappearing on narrow screens - Fixed bottom status info (clock, CLI version) getting cut off - Added flex-wrap and mobile-optimised layouts - Icons-only mode on screens < 640px - Vertical stacking on screens < 768px ## Testing Improvements ๐งช ### Comprehensive Test Coverage (#114) - **417 backend tests** (up from 408) - **387 frontend tests** (up from 363) - **61%+ backend code coverage** - Added E2E integration tests for cross-platform notification commands - New test files: `agents.test.ts`, comprehensive CLI parsing tests - Tests for `debug_logger.rs`, `bridge_manager.rs`, `notifications.rs` - Console mocking for cleaner test output - Fixed flaky frontend tests ### Testing Documentation - Updated CLAUDE.md with comprehensive testing guidelines - Documented mocking approaches (console mocking, E2E command structure testing) - Added step-by-step guide for adding tests to new features - Goal to maintain ~100% test coverage documented ## Closes Closes #114 Closes #118 Closes #122 Closes #128 Closes #129 Closes #130 Closes #131 Closes #132 Closes #133 Closes #134 Closes #136 ## Technical Details - All new backend commands properly registered in `lib.rs` - CLI output parsing with comprehensive test coverage - Cross-platform compatibility verified through E2E tests (Linux CI can test Windows commands) - Theme-aware UI components using CSS variables throughout - Proper TypeScript types for all new stores and components - ESLint and Prettier compliant - All Clippy warnings addressed โจ This PR was created with help from Hikari~ ๐ธ Reviewed-on: #135 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
829 lines
24 KiB
TypeScript
829 lines
24 KiB
TypeScript
import { writable, derived, get } from "svelte/store";
|
|
import type {
|
|
TerminalLine,
|
|
ConnectionStatus,
|
|
PermissionRequest,
|
|
UserQuestionEvent,
|
|
Attachment,
|
|
} from "$lib/types/messages";
|
|
import type { CharacterState } from "$lib/types/states";
|
|
import { cleanupConversationTracking } from "$lib/tauri";
|
|
import { characterState } from "$lib/stores/character";
|
|
import { sessionsStore } from "$lib/stores/sessions";
|
|
import { agentStore } from "$lib/stores/agents";
|
|
|
|
export interface ConversationSummary {
|
|
generatedAt: Date;
|
|
content: string;
|
|
messageCount: number;
|
|
tokenEstimate: number;
|
|
}
|
|
|
|
export interface Conversation {
|
|
id: string;
|
|
name: string;
|
|
terminalLines: TerminalLine[];
|
|
sessionId: string | null;
|
|
connectionStatus: ConnectionStatus;
|
|
workingDirectory: string;
|
|
characterState: CharacterState;
|
|
isProcessing: boolean;
|
|
grantedTools: Set<string>;
|
|
pendingPermissions: PermissionRequest[];
|
|
pendingQuestion: UserQuestionEvent | null;
|
|
scrollPosition: number;
|
|
createdAt: Date;
|
|
lastActivityAt: Date;
|
|
attachments: Attachment[];
|
|
summary: ConversationSummary | null;
|
|
startedAt: 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(),
|
|
pendingPermissions: [],
|
|
pendingQuestion: null,
|
|
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
|
createdAt: new Date(),
|
|
lastActivityAt: new Date(),
|
|
attachments: [],
|
|
summary: null,
|
|
startedAt: 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?.pendingPermissions[0] || null
|
|
);
|
|
const pendingPermissions = derived(
|
|
activeConversation,
|
|
($conv) => $conv?.pendingPermissions || []
|
|
);
|
|
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
|
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
|
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
|
|
|
|
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 },
|
|
pendingPermissions: { subscribe: pendingPermissions.subscribe },
|
|
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
|
isProcessing: { subscribe: isProcessing.subscribe },
|
|
grantedTools: { subscribe: grantedTools.subscribe },
|
|
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
|
scrollPosition: { subscribe: scrollPosition.subscribe },
|
|
attachments: { subscribe: attachments.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.pendingPermissions.push(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.pendingPermissions = [];
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
requestPermissionForConversation: (conversationId: string, request: PermissionRequest) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.pendingPermissions.push(request);
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
clearPermissionForConversation: (conversationId: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.pendingPermissions = [];
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
removePermission: (id: string) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
removePermissionForConversation: (conversationId: string, id: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.pendingPermissions = conv.pendingPermissions.filter((p) => p.id !== id);
|
|
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: async (id: string) => {
|
|
ensureInitialized();
|
|
const convs = get(conversations);
|
|
const activeId = get(activeConversationId);
|
|
|
|
if (convs.size <= 1) {
|
|
// Don't delete the last conversation
|
|
return false;
|
|
}
|
|
|
|
// Cancel any pending auto-save for this conversation
|
|
sessionsStore.cancelAutoSave(id);
|
|
|
|
// Clean up tracking for this conversation (including temp files)
|
|
await cleanupConversationTracking(id);
|
|
|
|
// Clean up agent tracking for this conversation
|
|
// This prevents the badge from persisting after tab close
|
|
agentStore.clearConversation(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,
|
|
cost?: TerminalLine["cost"],
|
|
parentToolUseId?: string
|
|
) => {
|
|
ensureInitialized();
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return "";
|
|
|
|
const line: TerminalLine = {
|
|
id: generateLineId(),
|
|
type,
|
|
content,
|
|
timestamp: new Date(),
|
|
toolName,
|
|
cost,
|
|
parentToolUseId,
|
|
};
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.terminalLines.push(line);
|
|
conv.lastActivityAt = new Date();
|
|
// Schedule auto-save for this conversation
|
|
sessionsStore.scheduleAutoSave(conv);
|
|
}
|
|
return convs;
|
|
});
|
|
|
|
return line.id;
|
|
},
|
|
|
|
addLineToConversation: (
|
|
conversationId: string,
|
|
type: TerminalLine["type"],
|
|
content: string,
|
|
toolName?: string,
|
|
cost?: TerminalLine["cost"],
|
|
parentToolUseId?: string
|
|
) => {
|
|
ensureInitialized();
|
|
|
|
const line: TerminalLine = {
|
|
id: generateLineId(),
|
|
type,
|
|
content,
|
|
timestamp: new Date(),
|
|
toolName,
|
|
cost,
|
|
parentToolUseId,
|
|
};
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.terminalLines.push(line);
|
|
conv.lastActivityAt = new Date();
|
|
// Schedule auto-save for this conversation
|
|
sessionsStore.scheduleAutoSave(conv);
|
|
}
|
|
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;
|
|
},
|
|
|
|
// Attachment management
|
|
addAttachment: (attachment: Attachment) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.attachments.push(attachment);
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
removeAttachment: (id: string) => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.attachments = conv.attachments.filter((a) => a.id !== id);
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
clearAttachments: () => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return;
|
|
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(activeId);
|
|
if (conv) {
|
|
conv.attachments = [];
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
getAttachments: (): Attachment[] => {
|
|
const activeId = get(activeConversationId);
|
|
if (!activeId) return [];
|
|
|
|
const convs = get(conversations);
|
|
const conv = convs.get(activeId);
|
|
return conv?.attachments || [];
|
|
},
|
|
|
|
// Summary/compaction functions
|
|
setSummary: (conversationId: string, summary: ConversationSummary) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.summary = summary;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
clearSummary: (conversationId: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.summary = null;
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
getSummary: (conversationId: string): ConversationSummary | null => {
|
|
const convs = get(conversations);
|
|
const conv = convs.get(conversationId);
|
|
return conv?.summary || null;
|
|
},
|
|
|
|
// Estimate token count for a conversation (rough approximation: ~4 chars per token)
|
|
estimateTokenCount: (conversationId: string): number => {
|
|
const convs = get(conversations);
|
|
const conv = convs.get(conversationId);
|
|
if (!conv) return 0;
|
|
|
|
const relevantLines = conv.terminalLines.filter(
|
|
(line) => line.type === "user" || line.type === "assistant"
|
|
);
|
|
|
|
const totalChars = relevantLines.reduce((sum, line) => sum + line.content.length, 0);
|
|
return Math.ceil(totalChars / 4);
|
|
},
|
|
|
|
// Get conversation content suitable for summarisation
|
|
getConversationForSummary: (conversationId: string): string => {
|
|
const convs = get(conversations);
|
|
const conv = convs.get(conversationId);
|
|
if (!conv) return "";
|
|
|
|
const relevantLines = conv.terminalLines.filter(
|
|
(line) => line.type === "user" || line.type === "assistant"
|
|
);
|
|
|
|
return relevantLines
|
|
.map((line) => {
|
|
const role = line.type === "user" ? "User" : "Assistant";
|
|
return `${role}: ${line.content}`;
|
|
})
|
|
.join("\n\n");
|
|
},
|
|
|
|
// Compact conversation by keeping only recent messages
|
|
compactConversation: (conversationId: string, keepRecentCount: number = 10) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv && conv.terminalLines.length > keepRecentCount) {
|
|
// Keep system messages and the most recent user/assistant messages
|
|
const systemLines = conv.terminalLines.filter(
|
|
(line) => line.type !== "user" && line.type !== "assistant"
|
|
);
|
|
const chatLines = conv.terminalLines.filter(
|
|
(line) => line.type === "user" || line.type === "assistant"
|
|
);
|
|
|
|
// Keep only the most recent chat messages
|
|
const recentChatLines = chatLines.slice(-keepRecentCount);
|
|
|
|
// Combine: system lines at original positions + recent chat lines
|
|
conv.terminalLines = [...systemLines.slice(-5), ...recentChatLines];
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
// Compact conversation with a summary - clears old messages and injects summary context
|
|
compactWithSummary: (
|
|
conversationId: string,
|
|
summaryContent: string,
|
|
messageCount: number,
|
|
tokenEstimate: number
|
|
) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
// Store the summary
|
|
conv.summary = {
|
|
generatedAt: new Date(),
|
|
content: summaryContent,
|
|
messageCount,
|
|
tokenEstimate,
|
|
};
|
|
|
|
// Clear all messages and add a context injection message
|
|
conv.terminalLines = [
|
|
{
|
|
id: generateLineId(),
|
|
type: "system",
|
|
content: `[Conversation compacted] Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens). Context preserved below.`,
|
|
timestamp: new Date(),
|
|
},
|
|
{
|
|
id: generateLineId(),
|
|
type: "system",
|
|
content: `Previous Session Context:\n${summaryContent}`,
|
|
timestamp: new Date(),
|
|
},
|
|
];
|
|
conv.lastActivityAt = new Date();
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
// Add initialization helper
|
|
initialize: () => {
|
|
ensureInitialized();
|
|
},
|
|
};
|
|
}
|
|
|
|
export const conversationsStore = createConversationsStore();
|
|
// Initialize immediately
|
|
conversationsStore.initialize();
|