Files
spiceflow/client/src/lib/components/MessageList.svelte
Adam Jeniski 5171059692 Add session eject feature and terminal UX improvements
- 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>
2026-01-20 23:37:06 -05:00

403 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>