wip: tabs

This commit is contained in:
2026-01-19 21:33:12 -08:00
parent 70fcaa8650
commit 2f4bfd4183
7 changed files with 703 additions and 117 deletions
+5 -1
View File
@@ -9,6 +9,10 @@
"opener:default", "opener:default",
"shell:allow-spawn", "shell:allow-spawn",
"shell:allow-stdin-write", "shell:allow-stdin-write",
"shell:allow-kill" "shell:allow-kill",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify"
] ]
} }
+232
View File
@@ -0,0 +1,232 @@
<script lang="ts">
import { claudeStore } from "$lib/stores/claude";
import { onMount } from "svelte";
import type { Conversation } from "$lib/stores/conversations";
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;
claudeStore.conversations.subscribe((convs) => {
conversations = convs;
});
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);
}
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;
}
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 handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
saveTabName();
} else if (event.key === "Escape") {
editingTabId = null;
editingName = "";
}
}
// 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)}
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"
autofocus
/>
{: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)' : ''}"
/>
<span
class="text-sm pr-6 max-w-[150px] truncate"
ondblclick={(e) => startEditing(id, conversation.name, e)}
>
{conversation.name}
</span>
{#if id !== activeConversationId && id === connectedConversationId}
<span class="text-xs text-[var(--text-tertiary)]" title="This tab has the active Claude connection">
(active)
</span>
{/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)&#10;Note: Only one tab can be connected at a time"
>
<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>
+4 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude"; import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate } from "svelte"; import { afterUpdate } from "svelte";
import ConversationTabs from "./ConversationTabs.svelte";
let terminalElement: HTMLDivElement; let terminalElement: HTMLDivElement;
let shouldAutoScroll = true; let shouldAutoScroll = true;
@@ -78,10 +79,12 @@
<span class="text-sm terminal-header-text ml-2">Terminal</span> <span class="text-sm terminal-header-text ml-2">Terminal</span>
</div> </div>
<ConversationTabs />
<div <div
bind:this={terminalElement} bind:this={terminalElement}
onscroll={handleScroll} 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} {#if lines.length === 0}
<div class="terminal-waiting italic"> <div class="terminal-waiting italic">
+55 -115
View File
@@ -1,125 +1,65 @@
import { writable, derived } from "svelte/store"; import { derived } from "svelte/store";
import type { ConnectionStatus, PermissionRequest } from "$lib/types/messages"; import { conversationsStore } from "./conversations";
import type { ConnectionStatus, PermissionRequest, TerminalLine } from "$lib/types/messages";
export interface TerminalLine { // Re-export TerminalLine type for backwards compatibility
id: string; export type { TerminalLine };
type: "user" | "assistant" | "system" | "tool" | "error";
content: string;
timestamp: Date;
toolName?: string;
}
function createClaudeStore() { // Re-export from conversations store for backwards compatibility
const connectionStatus = writable<ConnectionStatus>("disconnected"); export const claudeStore = {
const sessionId = writable<string | null>(null); // Existing subscriptions
const currentWorkingDirectory = writable<string>(""); connectionStatus: conversationsStore.connectionStatus,
const terminalLines = writable<TerminalLine[]>([]); sessionId: conversationsStore.sessionId,
const pendingPermission = writable<PermissionRequest | null>(null); currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
const isProcessing = writable<boolean>(false); terminalLines: conversationsStore.terminalLines,
const grantedTools = writable<Set<string>>(new Set()); pendingPermission: conversationsStore.pendingPermission,
const pendingRetryMessage = writable<string | null>(null); 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 { // Methods
return `line-${Date.now()}-${lineIdCounter++}`; setConnectionStatus: conversationsStore.setConnectionStatus,
} setSessionId: conversationsStore.setSessionId,
setWorkingDirectory: conversationsStore.setWorkingDirectory,
setProcessing: conversationsStore.setProcessing,
addLine: conversationsStore.addLine,
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 { // Conversation management
connectionStatus: { subscribe: connectionStatus.subscribe }, createConversation: conversationsStore.createConversation,
sessionId: { subscribe: sessionId.subscribe }, deleteConversation: conversationsStore.deleteConversation,
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe }, switchConversation: conversationsStore.switchConversation,
terminalLines: { subscribe: terminalLines.subscribe }, renameConversation: conversationsStore.renameConversation,
pendingPermission: { subscribe: pendingPermission.subscribe },
isProcessing: { subscribe: isProcessing.subscribe },
grantedTools: { subscribe: grantedTools.subscribe },
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
setConnectionStatus: (status: ConnectionStatus) => connectionStatus.set(status), getGrantedTools: (): string[] => {
setSessionId: (id: string | null) => sessionId.set(id), let tools: string[] = [];
setWorkingDirectory: (dir: string) => currentWorkingDirectory.set(dir), conversationsStore.grantedTools.subscribe((t) => (tools = Array.from(t)))();
setProcessing: (processing: boolean) => isProcessing.set(processing), return tools;
},
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => { reset: () => {
const line: TerminalLine = { // Reset only the active conversation
id: generateLineId(), conversationsStore.clearTerminal();
type, conversationsStore.setSessionId(null);
content, conversationsStore.setWorkingDirectory("");
timestamp: new Date(), conversationsStore.setProcessing(false);
toolName, conversationsStore.revokeAllTools();
}; },
terminalLines.update((lines) => [...lines, line]); };
return line.id;
},
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),
reset: () => {
connectionStatus.set("disconnected");
sessionId.set(null);
currentWorkingDirectory.set("");
terminalLines.set([]);
pendingPermission.set(null);
isProcessing.set(false);
grantedTools.set(new Set());
pendingRetryMessage.set(null);
},
};
}
export const claudeStore = createClaudeStore();
export const hasPermissionPending = derived( export const hasPermissionPending = derived(
claudeStore.pendingPermission, claudeStore.pendingPermission,
+392
View File
@@ -0,0 +1,392 @@
import { writable, derived, get } from "svelte/store";
import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages";
export interface Conversation {
id: string;
name: string;
terminalLines: TerminalLine[];
sessionId: string | null;
connectionStatus: ConnectionStatus;
workingDirectory: string;
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: "",
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());
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;
});
},
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;
}
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);
// Auto-reconnect logic
if (targetConv && targetConv.connectionStatus === "disconnected") {
// Check if another conversation is connected
let hasConnectedConv = false;
for (const [convId, conv] of convs) {
if (convId !== id && (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting")) {
hasConnectedConv = true;
break;
}
}
if (hasConnectedConv) {
// Add a note about the limitation
const lineId = generateLineId();
conversations.update((c) => {
const conv = c.get(id);
if (conv) {
conv.terminalLines.push({
id: lineId,
type: "system",
content: "Another tab is connected. Disconnect it first or use that tab.",
timestamp: new Date(),
});
}
return c;
});
}
}
}
},
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;
});
},
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;
});
},
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;
},
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();
+8
View File
@@ -1,3 +1,11 @@
export interface TerminalLine {
id: string;
type: "user" | "assistant" | "system" | "tool" | "error";
content: string;
timestamp: Date;
toolName?: string;
}
export interface SystemInitMessage { export interface SystemInitMessage {
type: "system"; type: "system";
subtype: "init"; subtype: "init";