generated from nhcarrigan/template
fa906684c2
## Summary - **fix**: `show_thinking_blocks` setting now persists across sessions — it was defined on the TypeScript side but missing from the Rust `HikariConfig` struct, so serde silently dropped it on every save/load - **feat**: Tool calls are now rendered as collapsible blocks matching the Extended Thinking block aesthetic, replacing the old inline dropdown approach - **feat**: Add configurable max output tokens setting - **feat**: Use random creative names for conversation tabs - **test**: Significantly expanded frontend unit test coverage - **docs**: Require tests for all changes in CLAUDE.md - **feat**: Allow users to specify a custom terminal font (Closes #176) - **feat**: Display friendly names for memory files derived from the first heading (Closes #177) - **feat**: Add custom UI font support for the app chrome (buttons, labels, tabs) - **fix**: Apply custom UI font to the full app interface — `.app-container` was hardcoded, blocking inheritance from `body`; also renamed "Custom Font" to "Custom Terminal Font" for clarity ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #175 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
978 lines
27 KiB
TypeScript
978 lines
27 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;
|
|
taskStartTime: number | null;
|
|
successSoundFired: boolean;
|
|
taskStartSoundFired: boolean;
|
|
draftText: string;
|
|
}
|
|
|
|
const TAB_NAMES = [
|
|
// Cosmic & celestial
|
|
"Starfall",
|
|
"Moonbeam",
|
|
"Nebula",
|
|
"Aurora",
|
|
"Stardust",
|
|
"Solstice",
|
|
"Comet",
|
|
"Eclipse",
|
|
"Zenith",
|
|
"Celestia",
|
|
"Nova",
|
|
"Quasar",
|
|
"Lyra",
|
|
"Andromeda",
|
|
"Twilight",
|
|
// Magical & fantastical
|
|
"Camelot",
|
|
"Reverie",
|
|
"Arcane",
|
|
"Spellbound",
|
|
"Mirage",
|
|
"Oracle",
|
|
"Seraphim",
|
|
"Ethereal",
|
|
"Labyrinth",
|
|
"Enchantment",
|
|
// Nature & cosy
|
|
"Sakura",
|
|
"Ember",
|
|
"Cascade",
|
|
"Zephyr",
|
|
"Serendipity",
|
|
"Solace",
|
|
"Blossom",
|
|
"Whisper",
|
|
"Dewdrop",
|
|
"Sunbeam",
|
|
"Willow",
|
|
"Clover",
|
|
"Honeybee",
|
|
"Buttercup",
|
|
"Dandelion",
|
|
// Japanese/anime-inspired
|
|
"Tsukimi",
|
|
"Hanami",
|
|
"Yozora",
|
|
"Hoshi",
|
|
"Koharu",
|
|
"Akari",
|
|
"Midori",
|
|
// Adventure & epic
|
|
"Odyssey",
|
|
"Wanderer",
|
|
"Horizon",
|
|
"Voyage",
|
|
"Pathfinder",
|
|
"Frontier",
|
|
// Whimsical & sweet
|
|
"Bubblegum",
|
|
"Marshmallow",
|
|
"Daydream",
|
|
"Whimsy",
|
|
"Jellybean",
|
|
"Sprinkle",
|
|
"Cupcake",
|
|
// Dreamy & poetic
|
|
"Revenant",
|
|
"Elysium",
|
|
"Halcyon",
|
|
"Ephemera",
|
|
"Serenade",
|
|
"Lullaby",
|
|
"Nocturne",
|
|
"Rhapsody",
|
|
];
|
|
|
|
function pickRandomTabName(): string {
|
|
return TAB_NAMES[Math.floor(Math.random() * TAB_NAMES.length)];
|
|
}
|
|
|
|
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 ?? pickRandomTabName(),
|
|
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(),
|
|
taskStartTime: null,
|
|
successSoundFired: false,
|
|
taskStartSoundFired: false,
|
|
draftText: "",
|
|
};
|
|
}
|
|
|
|
// Initialize with first conversation lazily
|
|
let initialized = false;
|
|
function ensureInitialized() {
|
|
if (!initialized) {
|
|
initialized = true;
|
|
const initialConversation = createNewConversation();
|
|
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 = [...conv.pendingPermissions, 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 = [...conv.pendingPermissions, 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.
|
|
// Map success/error → idle since those are transient states that have
|
|
// already been displayed — restoring them would re-trigger sound rules.
|
|
if (targetConv) {
|
|
const stateToRestore =
|
|
targetConv.characterState === "success" || targetConv.characterState === "error"
|
|
? "idle"
|
|
: targetConv.characterState;
|
|
characterState.setState(stateToRestore);
|
|
}
|
|
}
|
|
},
|
|
|
|
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;
|
|
});
|
|
},
|
|
|
|
// Sound tracking methods
|
|
resetSoundState: (conversationId: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.taskStartTime = null;
|
|
conv.successSoundFired = false;
|
|
conv.taskStartSoundFired = false;
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
setTaskStartTime: (conversationId: string, time: number | null) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.taskStartTime = time;
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
markSuccessSoundFired: (conversationId: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.successSoundFired = true;
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
markTaskStartSoundFired: (conversationId: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.taskStartSoundFired = true;
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
setDraftText: (conversationId: string, text: string) => {
|
|
conversations.update((convs) => {
|
|
const conv = convs.get(conversationId);
|
|
if (conv) {
|
|
conv.draftText = text;
|
|
}
|
|
return convs;
|
|
});
|
|
},
|
|
|
|
// Add initialization helper
|
|
initialize: () => {
|
|
ensureInitialized();
|
|
},
|
|
};
|
|
}
|
|
|
|
export const conversationsStore = createConversationsStore();
|
|
// Initialize immediately
|
|
conversationsStore.initialize();
|