- Add eject button for tmux sessions (keeps tmux running, removes from Spiceflow) - Add refresh button to session settings for all providers - Improve terminal controls: larger buttons, more zoom levels (50-150%), copy selection, paste clipboard, enter key - Fix session navigation: properly reload when switching between sessions - Update tmux screen presets to larger dimensions (fullscreen 260x48, desktop 120x48, landscape 80x24) - Add testing documentation to CLAUDE.md - Refactor E2E tests to use API-based cleanup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
403 lines
14 KiB
Svelte
403 lines
14 KiB
Svelte
<script lang="ts">
|
||
import type { Message, PermissionDenial, ToolInput, Session } from '$lib/api';
|
||
import { afterUpdate, tick } from 'svelte';
|
||
import { marked } from 'marked';
|
||
import FileDiff from './FileDiff.svelte';
|
||
|
||
export let messages: Message[] = [];
|
||
export let streamingContent: string = '';
|
||
export let isThinking: boolean = false;
|
||
export let provider: Session['provider'] = 'claude';
|
||
export let autoScroll: boolean = true;
|
||
|
||
// Configure marked for safe rendering
|
||
marked.setOptions({
|
||
breaks: true,
|
||
gfm: true
|
||
});
|
||
|
||
function renderMarkdown(content: string): string {
|
||
return marked.parse(content) as string;
|
||
}
|
||
|
||
let container: HTMLDivElement;
|
||
let collapsedMessages: Set<string> = new Set();
|
||
let hasScrolledInitially = false;
|
||
let lastMessageCount = 0;
|
||
|
||
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
|
||
|
||
function scrollToBottom() {
|
||
if (autoScroll && container) {
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
}
|
||
|
||
// Reset scroll flag when messages are cleared (navigating to new session)
|
||
$: if (messages.length === 0 && lastMessageCount > 0) {
|
||
hasScrolledInitially = false;
|
||
}
|
||
|
||
// Track message count for detecting session changes
|
||
$: lastMessageCount = messages.length;
|
||
|
||
// Scroll to bottom when messages first load
|
||
$: if (messages.length > 0 && container && !hasScrolledInitially) {
|
||
hasScrolledInitially = true;
|
||
// Use tick + setTimeout to ensure DOM is fully rendered
|
||
tick().then(() => {
|
||
setTimeout(() => {
|
||
scrollToBottom();
|
||
}, 50);
|
||
});
|
||
}
|
||
|
||
function toggleCollapse(id: string) {
|
||
if (collapsedMessages.has(id)) {
|
||
collapsedMessages.delete(id);
|
||
} else {
|
||
collapsedMessages.add(id);
|
||
}
|
||
collapsedMessages = collapsedMessages; // trigger reactivity
|
||
}
|
||
|
||
function shouldCollapse(content: string): boolean {
|
||
const lines = content.split('\n').length;
|
||
return lines > COLLAPSE_THRESHOLD;
|
||
}
|
||
|
||
export function condenseAll() {
|
||
messages.forEach((msg) => {
|
||
if (shouldCollapse(msg.content)) {
|
||
collapsedMessages.add(msg.id);
|
||
}
|
||
});
|
||
collapsedMessages = collapsedMessages; // trigger reactivity
|
||
}
|
||
|
||
export function expandAll() {
|
||
collapsedMessages.clear();
|
||
collapsedMessages = collapsedMessages; // trigger reactivity
|
||
}
|
||
|
||
afterUpdate(() => {
|
||
// Use requestAnimationFrame for consistent scroll behavior
|
||
requestAnimationFrame(() => {
|
||
scrollToBottom();
|
||
});
|
||
});
|
||
|
||
const roleStyles = {
|
||
user: 'bg-spice-500/20 border-spice-500/30',
|
||
assistant: 'bg-zinc-800 border-zinc-700',
|
||
system: 'bg-blue-500/20 border-blue-500/30 text-blue-200'
|
||
};
|
||
|
||
function isPermissionAccepted(message: Message): boolean {
|
||
return message.metadata?.type === 'permission-accepted';
|
||
}
|
||
|
||
function isPermissionRequest(message: Message): boolean {
|
||
return message.metadata?.type === 'permission-request';
|
||
}
|
||
|
||
function getPermissionStatus(message: Message): string | undefined {
|
||
return message.metadata?.status as string | undefined;
|
||
}
|
||
|
||
function getPermissionDenials(message: Message): PermissionDenial[] {
|
||
return (message.metadata?.denials as PermissionDenial[]) || [];
|
||
}
|
||
|
||
function isFileOperation(tool: string): boolean {
|
||
return tool === 'Write' || tool === 'Edit';
|
||
}
|
||
|
||
function getFilePath(tool: string, input: ToolInput): string {
|
||
if ((tool === 'Write' || tool === 'Edit') && input && typeof input === 'object') {
|
||
return (input as { file_path?: string }).file_path || '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// Track which denials are expanded in historical permission requests
|
||
let expandedHistoryDenials: Set<string> = new Set();
|
||
|
||
function toggleHistoryDenial(messageId: string, index: number) {
|
||
const key = `${messageId}-${index}`;
|
||
if (expandedHistoryDenials.has(key)) {
|
||
expandedHistoryDenials.delete(key);
|
||
} else {
|
||
expandedHistoryDenials.add(key);
|
||
}
|
||
expandedHistoryDenials = expandedHistoryDenials; // trigger reactivity
|
||
}
|
||
</script>
|
||
|
||
<div
|
||
bind:this={container}
|
||
class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-4"
|
||
>
|
||
{#if messages.length === 0 && !streamingContent && !isThinking}
|
||
<div class="h-full flex items-center justify-center">
|
||
<div class="text-center text-zinc-500">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
class="h-12 w-12 mx-auto mb-3 opacity-50"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="1.5"
|
||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||
/>
|
||
</svg>
|
||
<p>No messages yet</p>
|
||
<p class="text-sm mt-1">Send a message to start the conversation</p>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
{#each messages as message (message.id)}
|
||
{@const isCollapsed = collapsedMessages.has(message.id)}
|
||
{@const isCollapsible = shouldCollapse(message.content)}
|
||
{@const permStatus = getPermissionStatus(message)}
|
||
{#if isPermissionAccepted(message)}
|
||
<!-- Permission accepted message (legacy format) -->
|
||
<div class="rounded-lg border p-3 bg-green-500/20 border-green-500/30">
|
||
<div class="flex items-start gap-2">
|
||
<svg class="h-4 w-4 text-green-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
<div class="text-sm text-green-200 font-mono space-y-0.5">
|
||
{#each message.content.split('\n') as line}
|
||
<div>{line}</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{:else if isPermissionRequest(message)}
|
||
<!-- Permission request message with file diffs -->
|
||
{@const denials = getPermissionDenials(message)}
|
||
{@const statusColor = permStatus === 'accept' ? 'green' : permStatus === 'deny' ? 'red' : permStatus === 'steer' ? 'amber' : 'amber'}
|
||
{@const bgColor = permStatus === 'accept' ? 'bg-green-500/10 border-green-500/30' : permStatus === 'deny' ? 'bg-red-500/10 border-red-500/30' : permStatus === 'steer' ? 'bg-amber-500/10 border-amber-500/30' : 'bg-amber-500/10 border-amber-500/30'}
|
||
<div class="rounded-lg border p-3 {bgColor}">
|
||
<div class="flex items-start gap-3">
|
||
<!-- Status icon -->
|
||
<div class="flex-shrink-0 mt-0.5">
|
||
{#if permStatus === 'accept'}
|
||
<svg class="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
{:else if permStatus === 'deny'}
|
||
<svg class="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
{:else if permStatus === 'steer'}
|
||
<svg class="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
{:else}
|
||
<!-- Pending (shouldn't happen for historical, but fallback) -->
|
||
<svg class="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
</svg>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="flex-1 min-w-0">
|
||
<p class="text-sm font-medium {permStatus === 'accept' ? 'text-green-200' : permStatus === 'deny' ? 'text-red-200' : 'text-amber-200'}">
|
||
{#if permStatus === 'accept'}
|
||
Permission granted
|
||
{:else if permStatus === 'deny'}
|
||
Permission denied
|
||
{:else if permStatus === 'steer'}
|
||
Redirected
|
||
{:else}
|
||
Permission requested
|
||
{/if}
|
||
</p>
|
||
|
||
<ul class="mt-2 space-y-2">
|
||
{#each denials as denial, index}
|
||
<li class="text-sm">
|
||
{#if isFileOperation(denial.tool)}
|
||
<!-- Expandable file operation -->
|
||
<button
|
||
class="w-full text-left flex items-center gap-2 text-zinc-300 font-mono hover:text-white transition-colors"
|
||
on:click={() => toggleHistoryDenial(message.id, index)}
|
||
>
|
||
<svg
|
||
class="h-4 w-4 text-zinc-500 transition-transform {expandedHistoryDenials.has(`${message.id}-${index}`) ? 'rotate-90' : ''}"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||
</svg>
|
||
<span class="{permStatus === 'accept' ? 'text-green-400' : permStatus === 'deny' ? 'text-red-400' : 'text-amber-400'}">{denial.tool}:</span>
|
||
<span class="truncate">{denial.description}</span>
|
||
</button>
|
||
|
||
{#if expandedHistoryDenials.has(`${message.id}-${index}`)}
|
||
<div class="mt-2">
|
||
<FileDiff
|
||
tool={denial.tool}
|
||
input={denial.input}
|
||
filePath={getFilePath(denial.tool, denial.input)}
|
||
/>
|
||
</div>
|
||
{/if}
|
||
{:else}
|
||
<!-- Non-file operation (Bash, etc) -->
|
||
<div class="flex items-center gap-2 text-zinc-300 font-mono">
|
||
<span class="{permStatus === 'accept' ? 'text-green-400' : permStatus === 'deny' ? 'text-red-400' : 'text-amber-400'}">{denial.tool}:</span>
|
||
<span class="truncate">{denial.description}</span>
|
||
</div>
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<div
|
||
class="rounded-lg border p-3 {roleStyles[message.role]} {isCollapsible ? 'cursor-pointer' : ''} relative"
|
||
on:click={() => isCollapsible && toggleCollapse(message.id)}
|
||
on:keydown={(e) => e.key === 'Enter' && isCollapsible && toggleCollapse(message.id)}
|
||
role={isCollapsible ? 'button' : undefined}
|
||
tabindex={isCollapsible ? 0 : undefined}
|
||
>
|
||
{#if provider === 'tmux' && message.role === 'assistant'}
|
||
<!-- Terminal output styling for tmux -->
|
||
<pre
|
||
class="text-sm break-words font-mono leading-relaxed bg-black/40 rounded p-2 overflow-x-auto whitespace-pre-wrap text-green-300 {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
|
||
>{message.content}</pre>
|
||
{:else if provider === 'tmux' && message.role === 'user'}
|
||
<!-- Command input styling for tmux -->
|
||
<div
|
||
class="text-sm break-words font-mono leading-relaxed {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
|
||
>
|
||
<span class="text-cyan-400">$</span> {message.content}
|
||
</div>
|
||
{:else}
|
||
<div
|
||
class="text-sm break-words font-mono leading-relaxed markdown-content {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
|
||
>
|
||
{@html renderMarkdown(message.content)}
|
||
</div>
|
||
{/if}
|
||
{#if isCollapsible}
|
||
<span
|
||
class="absolute right-3 top-3 text-zinc-500 transition-transform {isCollapsed ? '' : 'rotate-90'}"
|
||
>
|
||
›
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
|
||
{#if isThinking && !streamingContent}
|
||
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||
<div class="flex items-center gap-1">
|
||
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
|
||
<span
|
||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||
style="animation-delay: 0.1s"
|
||
></span>
|
||
<span
|
||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||
style="animation-delay: 0.2s"
|
||
></span>
|
||
</div>
|
||
</div>
|
||
{:else if streamingContent}
|
||
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||
{#if provider === 'tmux'}
|
||
<pre
|
||
class="text-sm break-words font-mono leading-relaxed bg-black/40 rounded p-2 overflow-x-auto whitespace-pre-wrap text-green-300"
|
||
>{streamingContent}<span class="animate-pulse text-white">|</span></pre>
|
||
{:else}
|
||
<div class="text-sm break-words font-mono leading-relaxed markdown-content">
|
||
{@html renderMarkdown(streamingContent)}<span class="animate-pulse">|</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.markdown-content :global(p) {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.markdown-content :global(p:last-child) {
|
||
margin-bottom: 0;
|
||
}
|
||
.markdown-content :global(h1),
|
||
.markdown-content :global(h2),
|
||
.markdown-content :global(h3),
|
||
.markdown-content :global(h4) {
|
||
font-weight: 600;
|
||
margin-top: 1rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.markdown-content :global(h1) {
|
||
font-size: 1.25rem;
|
||
}
|
||
.markdown-content :global(h2) {
|
||
font-size: 1.125rem;
|
||
}
|
||
.markdown-content :global(h3) {
|
||
font-size: 1rem;
|
||
}
|
||
.markdown-content :global(code) {
|
||
background-color: rgba(0, 0, 0, 0.3);
|
||
padding: 0.125rem 0.25rem;
|
||
border-radius: 0.25rem;
|
||
font-size: 0.875em;
|
||
}
|
||
.markdown-content :global(pre) {
|
||
background-color: rgba(0, 0, 0, 0.3);
|
||
padding: 0.75rem;
|
||
border-radius: 0.375rem;
|
||
overflow-x: auto;
|
||
margin: 0.5rem 0;
|
||
}
|
||
.markdown-content :global(pre code) {
|
||
background: none;
|
||
padding: 0;
|
||
}
|
||
.markdown-content :global(ul),
|
||
.markdown-content :global(ol) {
|
||
padding-left: 1.5rem;
|
||
margin: 0.5rem 0;
|
||
}
|
||
.markdown-content :global(li) {
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.markdown-content :global(a) {
|
||
color: #f97316;
|
||
text-decoration: underline;
|
||
}
|
||
.markdown-content :global(a:hover) {
|
||
opacity: 0.8;
|
||
}
|
||
.markdown-content :global(blockquote) {
|
||
border-left: 3px solid #525252;
|
||
padding-left: 1rem;
|
||
margin: 0.5rem 0;
|
||
opacity: 0.9;
|
||
}
|
||
.markdown-content :global(strong) {
|
||
font-weight: 600;
|
||
}
|
||
.markdown-content :global(em) {
|
||
font-style: italic;
|
||
}
|
||
</style>
|