generated from nhcarrigan/template
wip: tabs
This commit is contained in:
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) 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>
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user