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,279 @@
|
||||
<script lang="ts">
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { onMount } from "svelte";
|
||||
import type { Conversation } from "$lib/stores/conversations";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
let conversations: Map<string, Conversation> = new Map();
|
||||
let activeConversationId: string | null = null;
|
||||
let editingTabId: string | null = null;
|
||||
let editingName = "";
|
||||
|
||||
// Track which conversation actually has the Claude connection
|
||||
let connectedConversationId: string | null = null;
|
||||
|
||||
// Track last seen message count for each conversation
|
||||
let lastSeenMessageCount = new SvelteMap<string, number>();
|
||||
|
||||
claudeStore.conversations.subscribe((convs) => {
|
||||
conversations = convs;
|
||||
|
||||
// Update the last seen count for the active conversation
|
||||
if (activeConversationId) {
|
||||
const activeConv = convs.get(activeConversationId);
|
||||
if (activeConv) {
|
||||
lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
claudeStore.activeConversationId.subscribe((id) => {
|
||||
activeConversationId = id;
|
||||
});
|
||||
|
||||
// Find the connected conversation
|
||||
$: {
|
||||
let foundConnected = false;
|
||||
for (const [id, conv] of conversations) {
|
||||
if (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting") {
|
||||
connectedConversationId = id;
|
||||
foundConnected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundConnected) {
|
||||
connectedConversationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function createNewTab() {
|
||||
claudeStore.createConversation();
|
||||
}
|
||||
|
||||
async function switchTab(id: string) {
|
||||
if (editingTabId) {
|
||||
saveTabName();
|
||||
}
|
||||
await claudeStore.switchConversation(id);
|
||||
|
||||
// Mark messages as seen when switching to this tab
|
||||
const conv = conversations.get(id);
|
||||
if (conv) {
|
||||
lastSeenMessageCount.set(id, conv.terminalLines.length);
|
||||
// Trigger reactivity
|
||||
lastSeenMessageCount = lastSeenMessageCount;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteTab(id: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (conversations.size > 1) {
|
||||
claudeStore.deleteConversation(id);
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(id: string, name: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
editingTabId = id;
|
||||
editingName = name;
|
||||
// Focus input after DOM update
|
||||
setTimeout(() => {
|
||||
const input = document.querySelector('.tab-item input[type="text"]') as HTMLInputElement;
|
||||
if (input) input.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function saveTabName() {
|
||||
if (editingTabId && editingName.trim()) {
|
||||
claudeStore.renameConversation(editingTabId, editingName.trim());
|
||||
}
|
||||
editingTabId = null;
|
||||
editingName = "";
|
||||
}
|
||||
|
||||
function getConnectionStatusColor(status: Conversation["connectionStatus"]): string {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return "bg-green-500";
|
||||
case "connecting":
|
||||
return "bg-yellow-500";
|
||||
case "disconnected":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
|
||||
if (id === activeConversationId) return false; // Active tab never has unread
|
||||
const lastSeen = lastSeenMessageCount.get(id) || 0;
|
||||
return conversation.terminalLines.length > lastSeen;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
saveTabName();
|
||||
} else if (event.key === "Escape") {
|
||||
editingTabId = null;
|
||||
editingName = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabKeydown(id: string, event: KeyboardEvent) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
switchTab(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
onMount(() => {
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// Ctrl/Cmd + T: New tab
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
||||
event.preventDefault();
|
||||
createNewTab();
|
||||
}
|
||||
// Ctrl/Cmd + W: Close current tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
|
||||
event.preventDefault();
|
||||
if (activeConversationId && conversations.size > 1) {
|
||||
claudeStore.deleteConversation(activeConversationId);
|
||||
}
|
||||
}
|
||||
// Ctrl/Cmd + Tab: Next tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from(conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const nextIndex = (currentIndex + 1) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[nextIndex]);
|
||||
}
|
||||
}
|
||||
// Ctrl/Cmd + Shift + Tab: Previous tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from(conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[prevIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleGlobalKeydown);
|
||||
return () => window.removeEventListener("keydown", handleGlobalKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
||||
>
|
||||
{#each Array.from(conversations.entries()) as [id, conversation] (id)}
|
||||
<div
|
||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
||||
{id === activeConversationId
|
||||
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
||||
onclick={() => switchTab(id)}
|
||||
onkeydown={(e) => handleTabKeydown(id, e)}
|
||||
role="tab"
|
||||
tabindex={0}
|
||||
aria-selected={id === activeConversationId}
|
||||
>
|
||||
{#if editingTabId === id}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingName}
|
||||
onblur={saveTabName}
|
||||
onkeydown={handleKeydown}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
|
||||
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId &&
|
||||
connectedConversationId
|
||||
? ' (Another tab is connected)'
|
||||
: ''}"
|
||||
></div>
|
||||
<span
|
||||
class="text-sm pr-6 max-w-[150px] truncate"
|
||||
ondblclick={(e) => startEditing(id, conversation.name, e)}
|
||||
role="button"
|
||||
tabindex={-1}
|
||||
>
|
||||
{conversation.name}
|
||||
</span>
|
||||
{#if id !== activeConversationId && id === connectedConversationId}
|
||||
<span
|
||||
class="text-xs text-[var(--text-tertiary)]"
|
||||
title="This tab has the Claude connection"
|
||||
>
|
||||
(connected)
|
||||
</span>
|
||||
{/if}
|
||||
{#if hasUnreadMessages(id, conversation)}
|
||||
<div
|
||||
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse"
|
||||
title="New messages"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if conversations.size > 1}
|
||||
<button
|
||||
onclick={(e) => deleteTab(id, e)}
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Close tab"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
onclick={createNewTab}
|
||||
class="new-tab-btn flex items-center justify-center w-7 h-7 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] transition-colors"
|
||||
title="New conversation (Ctrl+T)"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.terminal-tabs {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
console.log("ConversationTabs component loading...");
|
||||
</script>
|
||||
|
||||
<div class="terminal-tabs" style="background: red; height: 36px; color: white;">
|
||||
Debug: Tabs Component Loaded
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
@@ -45,8 +46,6 @@
|
||||
let messageToSend = formattedMessage;
|
||||
if (getShouldRestoreHistory()) {
|
||||
const savedHistory = getSavedHistory();
|
||||
console.log("Should restore history:", true);
|
||||
console.log("Saved history:", savedHistory);
|
||||
|
||||
if (savedHistory) {
|
||||
// Prepend the conversation history with a context message
|
||||
@@ -56,13 +55,9 @@ ${savedHistory}
|
||||
[Continuing conversation after reconnection:]
|
||||
User: ${formattedMessage}`;
|
||||
|
||||
console.log("Message with history:", messageToSend);
|
||||
|
||||
// Clear the restoration flags
|
||||
clearHistoryRestore();
|
||||
}
|
||||
} else {
|
||||
console.log("Should restore history:", false);
|
||||
}
|
||||
|
||||
// Reset notification state for new user message
|
||||
@@ -72,7 +67,14 @@ User: ${formattedMessage}`;
|
||||
characterState.setState("thinking");
|
||||
|
||||
try {
|
||||
await invoke("send_prompt", { message: messageToSend });
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: messageToSend,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send prompt:", error);
|
||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||
@@ -85,18 +87,18 @@ User: ${formattedMessage}`;
|
||||
async function handleInterrupt() {
|
||||
// Save the conversation history FIRST before anything else
|
||||
const history = claudeStore.getConversationHistory();
|
||||
console.log("Saving conversation history:", history);
|
||||
|
||||
if (history) {
|
||||
setSavedHistory(history);
|
||||
setShouldRestoreHistory(true);
|
||||
console.log("History saved and restoration flag set");
|
||||
} else {
|
||||
console.log("No history to save");
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke("interrupt_claude");
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Process interrupted - reconnecting...");
|
||||
characterState.setState("idle");
|
||||
|
||||
@@ -106,14 +108,20 @@ User: ${formattedMessage}`;
|
||||
// Auto-reconnect after a brief delay
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
|
||||
// Get current working directory before reconnecting
|
||||
const workingDir = await invoke<string>("get_working_directory");
|
||||
const workingDir = await invoke<string>("get_working_directory", { conversationId });
|
||||
|
||||
// Set the flag to skip greeting on next connection
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
// Reconnect to Claude
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { PermissionRequest } from "$lib/types/messages";
|
||||
@@ -45,12 +46,18 @@
|
||||
|
||||
// Stop current session and reconnect with new permissions
|
||||
try {
|
||||
await invoke("stop_claude");
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
// Small delay to ensure clean shutdown
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
allowed_tools: newGrantedTools,
|
||||
@@ -72,7 +79,10 @@ ${JSON.stringify(toolInput, null, 2)}
|
||||
|
||||
Please continue where we left off and retry that action now that you have permission.`;
|
||||
|
||||
await invoke("send_prompt", { message: contextMessage });
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: contextMessage,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reconnect:", error);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { configStore, type HikariConfig } from "$lib/stores/config";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
@@ -87,7 +88,12 @@
|
||||
];
|
||||
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: targetDir,
|
||||
model: currentConfig.model || null,
|
||||
@@ -105,7 +111,11 @@
|
||||
|
||||
async function handleDisconnect() {
|
||||
try {
|
||||
await invoke("stop_claude");
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("stop_claude", { conversationId });
|
||||
} catch (error) {
|
||||
console.error("Failed to stop Claude:", error);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||
import { afterUpdate } from "svelte";
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
|
||||
let terminalElement: HTMLDivElement;
|
||||
let shouldAutoScroll = true;
|
||||
@@ -78,10 +79,12 @@
|
||||
<span class="text-sm terminal-header-text ml-2">Terminal</span>
|
||||
</div>
|
||||
|
||||
<ConversationTabs />
|
||||
|
||||
<div
|
||||
bind:this={terminalElement}
|
||||
onscroll={handleScroll}
|
||||
class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm"
|
||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono text-sm"
|
||||
>
|
||||
{#if lines.length === 0}
|
||||
<div class="terminal-waiting italic">
|
||||
|
||||
+74
-135
@@ -1,147 +1,86 @@
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { ConnectionStatus, PermissionRequest } from "$lib/types/messages";
|
||||
import { derived } from "svelte/store";
|
||||
import { conversationsStore } from "./conversations";
|
||||
import type { TerminalLine } from "$lib/types/messages";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import {
|
||||
setShouldRestoreHistory,
|
||||
setSavedHistory,
|
||||
getShouldRestoreHistory,
|
||||
getSavedHistory,
|
||||
clearHistoryRestore,
|
||||
} from "./historyRestore";
|
||||
|
||||
export interface TerminalLine {
|
||||
id: string;
|
||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
}
|
||||
// Re-export TerminalLine type for backwards compatibility
|
||||
export type { TerminalLine };
|
||||
|
||||
function createClaudeStore() {
|
||||
const connectionStatus = writable<ConnectionStatus>("disconnected");
|
||||
const sessionId = writable<string | null>(null);
|
||||
const currentWorkingDirectory = writable<string>("");
|
||||
const terminalLines = writable<TerminalLine[]>([]);
|
||||
const pendingPermission = writable<PermissionRequest | null>(null);
|
||||
const isProcessing = writable<boolean>(false);
|
||||
const grantedTools = writable<Set<string>>(new Set());
|
||||
const pendingRetryMessage = writable<string | null>(null);
|
||||
const shouldRestoreHistory = writable<boolean>(false);
|
||||
const savedConversationHistory = writable<string | null>(null);
|
||||
// Re-export from conversations store for backwards compatibility
|
||||
export const claudeStore = {
|
||||
// Existing subscriptions
|
||||
connectionStatus: conversationsStore.connectionStatus,
|
||||
sessionId: conversationsStore.sessionId,
|
||||
currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
|
||||
terminalLines: conversationsStore.terminalLines,
|
||||
pendingPermission: conversationsStore.pendingPermission,
|
||||
isProcessing: conversationsStore.isProcessing,
|
||||
grantedTools: conversationsStore.grantedTools,
|
||||
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
||||
|
||||
let lineIdCounter = 0;
|
||||
// New conversation-aware subscriptions
|
||||
conversations: conversationsStore.conversations,
|
||||
activeConversationId: conversationsStore.activeConversationId,
|
||||
activeConversation: conversationsStore.activeConversation,
|
||||
|
||||
function generateLineId(): string {
|
||||
return `line-${Date.now()}-${lineIdCounter++}`;
|
||||
}
|
||||
// Methods
|
||||
setConnectionStatus: conversationsStore.setConnectionStatus,
|
||||
setConnectionStatusForConversation: conversationsStore.setConnectionStatusForConversation,
|
||||
setCharacterStateForConversation: conversationsStore.setCharacterStateForConversation,
|
||||
setSessionId: conversationsStore.setSessionId,
|
||||
setSessionIdForConversation: conversationsStore.setSessionIdForConversation,
|
||||
setWorkingDirectory: conversationsStore.setWorkingDirectory,
|
||||
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
|
||||
setProcessing: conversationsStore.setProcessing,
|
||||
addLine: conversationsStore.addLine,
|
||||
addLineToConversation: conversationsStore.addLineToConversation,
|
||||
updateLine: conversationsStore.updateLine,
|
||||
appendToLine: conversationsStore.appendToLine,
|
||||
clearTerminal: conversationsStore.clearTerminal,
|
||||
getConversationHistory: conversationsStore.getConversationHistory,
|
||||
requestPermission: conversationsStore.requestPermission,
|
||||
clearPermission: conversationsStore.clearPermission,
|
||||
grantTool: conversationsStore.grantTool,
|
||||
revokeAllTools: conversationsStore.revokeAllTools,
|
||||
isToolGranted: conversationsStore.isToolGranted,
|
||||
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
||||
|
||||
return {
|
||||
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 },
|
||||
shouldRestoreHistory: { subscribe: shouldRestoreHistory.subscribe },
|
||||
savedConversationHistory: { subscribe: savedConversationHistory.subscribe },
|
||||
// Conversation management
|
||||
createConversation: conversationsStore.createConversation,
|
||||
deleteConversation: conversationsStore.deleteConversation,
|
||||
switchConversation: conversationsStore.switchConversation,
|
||||
renameConversation: conversationsStore.renameConversation,
|
||||
|
||||
setConnectionStatus: (status: ConnectionStatus) => connectionStatus.set(status),
|
||||
setSessionId: (id: string | null) => sessionId.set(id),
|
||||
setWorkingDirectory: (dir: string) => currentWorkingDirectory.set(dir),
|
||||
setProcessing: (processing: boolean) => isProcessing.set(processing),
|
||||
getGrantedTools: (): string[] => {
|
||||
let tools: string[] = [];
|
||||
conversationsStore.grantedTools.subscribe((t) => (tools = Array.from(t)))();
|
||||
return tools;
|
||||
},
|
||||
|
||||
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
|
||||
const line: TerminalLine = {
|
||||
id: generateLineId(),
|
||||
type,
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
toolName,
|
||||
};
|
||||
terminalLines.update((lines) => [...lines, line]);
|
||||
return line.id;
|
||||
},
|
||||
// History restoration methods from main branch
|
||||
setShouldRestoreHistory: setShouldRestoreHistory,
|
||||
setSavedConversationHistory: setSavedHistory,
|
||||
getShouldRestoreHistory: getShouldRestoreHistory,
|
||||
getSavedConversationHistory: getSavedHistory,
|
||||
|
||||
updateLine: (id: string, content: string) => {
|
||||
terminalLines.update((lines) =>
|
||||
lines.map((line) => (line.id === id ? { ...line, content } : line))
|
||||
);
|
||||
},
|
||||
|
||||
appendToLine: (id: string, additionalContent: string) => {
|
||||
terminalLines.update((lines) =>
|
||||
lines.map((line) =>
|
||||
line.id === id ? { ...line, content: line.content + additionalContent } : line
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
clearTerminal: () => terminalLines.set([]),
|
||||
|
||||
getConversationHistory: (): string => {
|
||||
let lines: TerminalLine[] = [];
|
||||
terminalLines.subscribe((l) => (lines = l))();
|
||||
|
||||
// Filter to just user and assistant messages, skip system/tool noise
|
||||
const relevantLines = lines.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");
|
||||
},
|
||||
|
||||
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
|
||||
clearPermission: () => pendingPermission.set(null),
|
||||
|
||||
grantTool: (toolName: string) => {
|
||||
grantedTools.update((tools) => {
|
||||
const newTools = new Set(tools);
|
||||
newTools.add(toolName);
|
||||
return newTools;
|
||||
});
|
||||
},
|
||||
|
||||
getGrantedTools: (): string[] => {
|
||||
let tools: string[] = [];
|
||||
grantedTools.subscribe((t) => (tools = Array.from(t)))();
|
||||
return tools;
|
||||
},
|
||||
|
||||
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||
|
||||
setShouldRestoreHistory: (should: boolean) => shouldRestoreHistory.set(should),
|
||||
setSavedConversationHistory: (history: string | null) => savedConversationHistory.set(history),
|
||||
|
||||
getShouldRestoreHistory: (): boolean => {
|
||||
let should = false;
|
||||
shouldRestoreHistory.subscribe((s) => (should = s))();
|
||||
return should;
|
||||
},
|
||||
|
||||
getSavedConversationHistory: (): string | null => {
|
||||
let history: string | null = null;
|
||||
savedConversationHistory.subscribe((h) => (history = h))();
|
||||
return history;
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
connectionStatus.set("disconnected");
|
||||
sessionId.set(null);
|
||||
currentWorkingDirectory.set("");
|
||||
terminalLines.set([]);
|
||||
pendingPermission.set(null);
|
||||
isProcessing.set(false);
|
||||
grantedTools.set(new Set());
|
||||
pendingRetryMessage.set(null);
|
||||
shouldRestoreHistory.set(false);
|
||||
savedConversationHistory.set(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const claudeStore = createClaudeStore();
|
||||
reset: () => {
|
||||
// Reset only the active conversation
|
||||
conversationsStore.clearTerminal();
|
||||
conversationsStore.setSessionId(null);
|
||||
conversationsStore.setWorkingDirectory("");
|
||||
conversationsStore.setProcessing(false);
|
||||
conversationsStore.revokeAllTools();
|
||||
// Also clear history restoration
|
||||
clearHistoryRestore();
|
||||
},
|
||||
};
|
||||
|
||||
export const hasPermissionPending = derived(
|
||||
claudeStore.pendingPermission,
|
||||
|
||||
@@ -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();
|
||||
@@ -4,26 +4,21 @@ let savedHistory: string | null = null;
|
||||
|
||||
export function setShouldRestoreHistory(should: boolean) {
|
||||
shouldRestore = should;
|
||||
console.log("Setting shouldRestoreHistory to:", should);
|
||||
}
|
||||
|
||||
export function setSavedHistory(history: string | null) {
|
||||
savedHistory = history;
|
||||
console.log("Setting savedHistory, length:", history?.length || 0);
|
||||
}
|
||||
|
||||
export function getShouldRestoreHistory(): boolean {
|
||||
console.log("Getting shouldRestoreHistory:", shouldRestore);
|
||||
return shouldRestore;
|
||||
}
|
||||
|
||||
export function getSavedHistory(): string | null {
|
||||
console.log("Getting savedHistory, length:", savedHistory?.length || 0);
|
||||
return savedHistory;
|
||||
}
|
||||
|
||||
export function clearHistoryRestore() {
|
||||
console.log("Clearing history restore flags");
|
||||
shouldRestore = false;
|
||||
savedHistory = null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ const messageModeStore = writable<string>("chat");
|
||||
export const messageMode = {
|
||||
subscribe: messageModeStore.subscribe,
|
||||
set: (mode: string) => {
|
||||
console.log("Setting message mode to:", mode);
|
||||
messageModeStore.set(mode);
|
||||
},
|
||||
reset: () => messageModeStore.set("chat"),
|
||||
|
||||
+167
-48
@@ -1,5 +1,6 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
@@ -17,9 +18,10 @@ import {
|
||||
interface StateChangePayload {
|
||||
state: CharacterState;
|
||||
tool_name: string | null;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
let hasConnectedThisSession = false;
|
||||
const connectedConversations = new Set<string>();
|
||||
let unlisteners: Array<() => void> = [];
|
||||
let skipNextGreeting = false;
|
||||
|
||||
@@ -46,7 +48,7 @@ function generateGreetingPrompt(): string {
|
||||
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
|
||||
}
|
||||
|
||||
async function sendGreeting() {
|
||||
async function sendGreeting(conversationId: string) {
|
||||
// Check if we should skip this greeting
|
||||
if (skipNextGreeting) {
|
||||
skipNextGreeting = false; // Reset the flag
|
||||
@@ -68,10 +70,13 @@ async function sendGreeting() {
|
||||
handleNewUserMessage();
|
||||
|
||||
try {
|
||||
await invoke("send_prompt", { message: greetingPrompt });
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: greetingPrompt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send greeting:", error);
|
||||
claudeStore.addLine("error", `Failed to send greeting: ${error}`);
|
||||
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +85,26 @@ interface OutputPayload {
|
||||
line_type: string;
|
||||
content: string;
|
||||
tool_name: string | null;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
interface ConnectionPayload {
|
||||
status: ConnectionStatus;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
interface SessionPayload {
|
||||
session_id: string;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
interface WorkingDirectoryPayload {
|
||||
directory: string;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
export function cleanupConversationTracking(conversationId: string) {
|
||||
connectedConversations.delete(conversationId);
|
||||
}
|
||||
|
||||
export async function initializeTauriListeners() {
|
||||
@@ -95,43 +120,78 @@ export async function initializeTauriListeners() {
|
||||
// Initialize achievements listener
|
||||
await initAchievementsListener();
|
||||
|
||||
const connectionUnlisten = await listen<string>("claude:connection", async (event) => {
|
||||
const status = event.payload as ConnectionStatus;
|
||||
claudeStore.setConnectionStatus(status);
|
||||
const connectionUnlisten = await listen<ConnectionPayload>("claude:connection", async (event) => {
|
||||
const { status, conversation_id } = event.payload;
|
||||
|
||||
// Update connection status for the specific conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.setConnectionStatusForConversation(conversation_id, status);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.setConnectionStatus(status);
|
||||
}
|
||||
|
||||
// Handle notification for connection status
|
||||
handleConnectionStatusChange(status);
|
||||
|
||||
if (status === "connected") {
|
||||
claudeStore.addLine("system", "Connected to Claude Code");
|
||||
characterState.setState("idle");
|
||||
if (!hasConnectedThisSession) {
|
||||
hasConnectedThisSession = true;
|
||||
resetSessionStats(); // Reset session stats on new connection
|
||||
await sendGreeting();
|
||||
// Get the actual conversation ID (fallback to active if not provided)
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
|
||||
if (targetConversationId) {
|
||||
// Add system message to the correct conversation
|
||||
claudeStore.addLineToConversation(
|
||||
targetConversationId,
|
||||
"system",
|
||||
"Connected to Claude Code"
|
||||
);
|
||||
|
||||
// Update character state for this conversation
|
||||
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
|
||||
|
||||
// Check if this specific conversation has connected before
|
||||
if (!connectedConversations.has(targetConversationId)) {
|
||||
connectedConversations.add(targetConversationId);
|
||||
resetSessionStats(); // Reset session stats on new connection
|
||||
await sendGreeting(targetConversationId);
|
||||
}
|
||||
}
|
||||
} else if (status === "disconnected") {
|
||||
// Only reset session flag if we're not about to reconnect
|
||||
if (!skipNextGreeting) {
|
||||
hasConnectedThisSession = false;
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
|
||||
// Only remove from connected set if we're not about to reconnect
|
||||
if (!skipNextGreeting && targetConversationId) {
|
||||
connectedConversations.delete(targetConversationId);
|
||||
}
|
||||
|
||||
// Don't add system message if we're about to reconnect
|
||||
if (!skipNextGreeting) {
|
||||
claudeStore.addLine("system", "Disconnected from Claude Code");
|
||||
if (!skipNextGreeting && targetConversationId) {
|
||||
claudeStore.addLineToConversation(
|
||||
targetConversationId,
|
||||
"system",
|
||||
"Disconnected from Claude Code"
|
||||
);
|
||||
}
|
||||
|
||||
characterState.setState("idle");
|
||||
// Update character state for this conversation
|
||||
if (targetConversationId) {
|
||||
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
|
||||
}
|
||||
} else if (status === "error") {
|
||||
hasConnectedThisSession = false;
|
||||
claudeStore.addLine("error", "Connection error");
|
||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||
|
||||
if (targetConversationId) {
|
||||
connectedConversations.delete(targetConversationId);
|
||||
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
|
||||
}
|
||||
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
});
|
||||
unlisteners.push(connectionUnlisten);
|
||||
|
||||
const stateUnlisten = await listen<StateChangePayload>("claude:state", (event) => {
|
||||
const { state } = event.payload;
|
||||
const { state, conversation_id } = event.payload;
|
||||
|
||||
const stateMap: Record<string, CharacterState> = {
|
||||
idle: "idle",
|
||||
@@ -147,21 +207,48 @@ export async function initializeTauriListeners() {
|
||||
|
||||
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
||||
|
||||
if (mappedState === "success" || mappedState === "error") {
|
||||
characterState.setTemporaryState(mappedState, 3000);
|
||||
// Always update the conversation's state
|
||||
if (conversation_id) {
|
||||
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
||||
} else {
|
||||
characterState.setState(mappedState);
|
||||
// Fallback to active conversation if no conversation_id
|
||||
const activeConversationId = get(claudeStore.activeConversationId);
|
||||
if (activeConversationId) {
|
||||
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
|
||||
}
|
||||
}
|
||||
|
||||
// Only update global character state for active conversation
|
||||
const activeConversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversation_id || conversation_id === activeConversationId) {
|
||||
if (mappedState === "success" || mappedState === "error") {
|
||||
characterState.setTemporaryState(mappedState, 3000);
|
||||
} else {
|
||||
characterState.setState(mappedState);
|
||||
}
|
||||
}
|
||||
});
|
||||
unlisteners.push(stateUnlisten);
|
||||
|
||||
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
|
||||
const { line_type, content, tool_name } = event.payload;
|
||||
claudeStore.addLine(
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
content,
|
||||
tool_name || undefined
|
||||
);
|
||||
const { line_type, content, tool_name, conversation_id } = event.payload;
|
||||
|
||||
// Always store the output to the correct conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
content,
|
||||
tool_name || undefined
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id provided
|
||||
claudeStore.addLine(
|
||||
line_type as "user" | "assistant" | "system" | "tool" | "error",
|
||||
content,
|
||||
tool_name || undefined
|
||||
);
|
||||
}
|
||||
});
|
||||
unlisteners.push(outputUnlisten);
|
||||
|
||||
@@ -170,30 +257,64 @@ export async function initializeTauriListeners() {
|
||||
});
|
||||
unlisteners.push(streamUnlisten);
|
||||
|
||||
const sessionUnlisten = await listen<string>("claude:session", (event) => {
|
||||
claudeStore.setSessionId(event.payload);
|
||||
claudeStore.addLine("system", `Session: ${event.payload.substring(0, 8)}...`);
|
||||
const sessionUnlisten = await listen<SessionPayload>("claude:session", (event) => {
|
||||
const { session_id, conversation_id } = event.payload;
|
||||
|
||||
// Store session ID for the correct conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.setSessionIdForConversation(conversation_id, session_id);
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
"system",
|
||||
`Session: ${session_id.substring(0, 8)}...`
|
||||
);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.setSessionId(session_id);
|
||||
claudeStore.addLine("system", `Session: ${session_id.substring(0, 8)}...`);
|
||||
}
|
||||
});
|
||||
unlisteners.push(sessionUnlisten);
|
||||
|
||||
const cwdUnlisten = await listen<string>("claude:cwd", (event) => {
|
||||
claudeStore.setWorkingDirectory(event.payload);
|
||||
const cwdUnlisten = await listen<WorkingDirectoryPayload>("claude:cwd", (event) => {
|
||||
const { directory, conversation_id } = event.payload;
|
||||
|
||||
// Store working directory for the correct conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.setWorkingDirectoryForConversation(conversation_id, directory);
|
||||
} else {
|
||||
// Fallback to active conversation if no conversation_id
|
||||
claudeStore.setWorkingDirectory(directory);
|
||||
}
|
||||
});
|
||||
unlisteners.push(cwdUnlisten);
|
||||
|
||||
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
|
||||
const { id, tool_name, tool_input, description } = event.payload;
|
||||
claudeStore.requestPermission({
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
||||
const { id, tool_name, tool_input, description, conversation_id } = event.payload;
|
||||
|
||||
// Only process permission requests for the active conversation
|
||||
const activeConversationId = get(claudeStore.activeConversationId);
|
||||
if (conversation_id === activeConversationId) {
|
||||
claudeStore.requestPermission({
|
||||
id,
|
||||
tool: tool_name,
|
||||
description,
|
||||
input: tool_input,
|
||||
});
|
||||
}
|
||||
|
||||
// Always store the permission message to the correct conversation
|
||||
if (conversation_id) {
|
||||
claudeStore.addLineToConversation(
|
||||
conversation_id,
|
||||
"system",
|
||||
`Permission requested for: ${tool_name}`
|
||||
);
|
||||
} else if (conversation_id === activeConversationId) {
|
||||
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
|
||||
}
|
||||
});
|
||||
unlisteners.push(permissionUnlisten);
|
||||
|
||||
console.log("Tauri event listeners initialized");
|
||||
}
|
||||
|
||||
export function cleanupTauriListeners() {
|
||||
@@ -203,6 +324,4 @@ export function cleanupTauriListeners() {
|
||||
|
||||
// Cleanup notification rules
|
||||
cleanupNotificationRules();
|
||||
|
||||
console.log("Tauri event listeners cleaned up");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export interface TerminalLine {
|
||||
id: string;
|
||||
type: "user" | "assistant" | "system" | "tool" | "error";
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
export interface SystemInitMessage {
|
||||
type: "system";
|
||||
subtype: "init";
|
||||
@@ -115,6 +123,7 @@ export interface PermissionPromptEvent {
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
description: string;
|
||||
conversation_id?: string;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||
|
||||
Reference in New Issue
Block a user