feat: tabs seem to be working now with multi connections

This commit is contained in:
2026-01-20 10:52:44 -08:00
parent 6a811d2016
commit 861b44797e
14 changed files with 550 additions and 103 deletions
@@ -11,8 +11,19 @@
// Track which conversation actually has the Claude connection
let connectedConversationId: string | null = null;
// Track last seen message count for each conversation
let lastSeenMessageCount: Map<string, number> = new Map();
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) => {
@@ -43,6 +54,14 @@
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) {
@@ -79,6 +98,12 @@
}
}
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();
@@ -171,6 +196,12 @@
(active)
</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"
/>
{/if}
</div>
{/if}
+21 -3
View File
@@ -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";
@@ -72,7 +73,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}`);
@@ -96,7 +104,11 @@ User: ${formattedMessage}`;
}
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 +118,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,
},
+12 -2
View File
@@ -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);
+11 -1
View File
@@ -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);
}
+4
View File
@@ -32,10 +32,14 @@ export const claudeStore = {
// Methods
setConnectionStatus: conversationsStore.setConnectionStatus,
setConnectionStatusForConversation: conversationsStore.setConnectionStatusForConversation,
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,
+60
View File
@@ -1,5 +1,6 @@
import { writable, derived, get } from "svelte/store";
import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages";
import { cleanupConversationTracking } from "$lib/tauri";
export interface Conversation {
id: string;
@@ -110,6 +111,17 @@ function createConversationsStore() {
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;
});
},
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
clearPermission: () => pendingPermission.set(null),
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
@@ -136,6 +148,9 @@ function createConversationsStore() {
return false;
}
// Clean up tracking for this conversation
cleanupConversationTracking(id);
conversations.update((c) => {
c.delete(id);
return c;
@@ -220,6 +235,17 @@ function createConversationsStore() {
});
},
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;
@@ -234,6 +260,17 @@ function createConversationsStore() {
});
},
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;
@@ -273,6 +310,29 @@ function createConversationsStore() {
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;
+143 -40
View File
@@ -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;
let 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,72 @@ 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");
characterState.setState("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");
} 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;
// Only process events for the active conversation
const activeConversationId = get(claudeStore.activeConversationId);
if (conversation_id && activeConversationId && conversation_id !== activeConversationId) {
return;
}
const stateMap: Record<string, CharacterState> = {
idle: "idle",
@@ -156,12 +210,33 @@ export async function initializeTauriListeners() {
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;
// Debug log
const activeId = get(claudeStore.activeConversationId);
console.log("claude:output event", {
conversation_id,
activeConversationId: activeId,
line_type,
content: content.substring(0, 50) + "...",
});
// 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,26 +245,54 @@ 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);
+1
View File
@@ -123,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";
+5
View File
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from "svelte";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import { configStore, applyTheme } from "$lib/stores/config";
import { conversationsStore } from "$lib/stores/conversations";
import "$lib/notifications/testNotifications";
import Terminal from "$lib/components/Terminal.svelte";
import InputBar from "$lib/components/InputBar.svelte";
@@ -18,6 +19,10 @@
onMount(async () => {
if (!initialized) {
initialized = true;
// Initialize conversations store first to ensure activeConversationId is set
conversationsStore.initialize();
await initializeTauriListeners();
await configStore.loadConfig();