feat: add ability to run multiple agents via tabbed views (#47)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 54s
CI / Lint & Test (push) Successful in 14m18s
CI / Build Linux (push) Successful in 16m46s
CI / Build Windows (cross-compile) (push) Successful in 26m39s

### 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:
2026-01-20 13:57:48 -08:00
committed by Naomi Carrigan
parent 2d3adcab1c
commit d83697e5cf
20 changed files with 1375 additions and 287 deletions
+279
View File
@@ -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>
+21 -13
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";
@@ -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,
},
+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 -1
View File
@@ -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">