add git diffs and permission support

This commit is contained in:
2026-01-19 23:45:03 -05:00
parent 313ac44337
commit 61a2e9b8af
44 changed files with 2051 additions and 267 deletions
+15
View File
@@ -7,6 +7,9 @@
"": {
"name": "spiceflow-client",
"version": "0.1.0",
"dependencies": {
"marked": "^17.0.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.0",
@@ -5005,6 +5008,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+3
View File
@@ -26,5 +26,8 @@
"vite": "^5.0.12",
"vite-plugin-pwa": "^0.19.2",
"workbox-window": "^7.0.0"
},
"dependencies": {
"marked": "^17.0.1"
}
}
+14
View File
@@ -61,4 +61,18 @@
.safe-top {
padding-top: env(safe-area-inset-top, 0);
}
/* Landscape mobile - short viewport height indicates landscape on mobile */
.landscape-menu {
display: none;
}
@media (max-height: 450px) {
.landscape-mobile\:hidden {
display: none !important;
}
.landscape-menu {
display: block;
}
}
}
+64 -2
View File
@@ -8,12 +8,16 @@ export interface Session {
title?: string;
'working-dir'?: string;
workingDir?: string;
status: 'idle' | 'running' | 'completed';
status: 'idle' | 'processing' | 'awaiting-permission';
'auto-accept-edits'?: boolean;
autoAcceptEdits?: boolean;
'created-at'?: string;
createdAt?: string;
'updated-at'?: string;
updatedAt?: string;
messages?: Message[];
'pending-permission'?: PermissionRequest;
pendingPermission?: PermissionRequest;
}
export interface Message {
@@ -27,9 +31,26 @@ export interface Message {
createdAt?: string;
}
export interface WriteToolInput {
file_path: string;
content: string;
}
export interface EditToolInput {
file_path: string;
old_string: string;
new_string: string;
}
export interface BashToolInput {
command: string;
}
export type ToolInput = WriteToolInput | EditToolInput | BashToolInput | Record<string, unknown>;
export interface PermissionDenial {
tool: string;
input: Record<string, unknown>;
input: ToolInput;
description: string;
}
@@ -47,6 +68,7 @@ export interface StreamEvent {
type?: string;
message?: string;
cwd?: string;
'working-dir'?: string;
'permission-request'?: PermissionRequest;
permissionRequest?: PermissionRequest;
}
@@ -141,6 +163,10 @@ export class WebSocketClient {
private reconnectDelay = 1000;
private listeners: Map<string, Set<(event: StreamEvent) => void>> = new Map();
private globalListeners: Set<(event: StreamEvent) => void> = new Set();
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private heartbeatTimeoutMs = 25000; // Send ping every 25 seconds
private lastPongTime: number = 0;
private pongTimeoutMs = 10000; // Consider connection dead if no pong within 10 seconds
constructor(url: string = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/ws`) {
this.url = url;
@@ -158,11 +184,13 @@ export class WebSocketClient {
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
this.startHeartbeat();
resolve();
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.stopHeartbeat();
this.attemptReconnect();
};
@@ -174,6 +202,7 @@ export class WebSocketClient {
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as StreamEvent;
console.log('[WS Raw] Message received:', data.event || data.type, data);
this.handleMessage(data);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
@@ -183,6 +212,12 @@ export class WebSocketClient {
}
private handleMessage(event: StreamEvent) {
// Track pong responses for heartbeat
if (event.type === 'pong') {
this.lastPongTime = Date.now();
return;
}
// Notify global listeners
this.globalListeners.forEach((listener) => listener(event));
@@ -194,6 +229,32 @@ export class WebSocketClient {
}
}
private startHeartbeat() {
this.stopHeartbeat();
this.lastPongTime = Date.now();
this.heartbeatInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
// Check if we received a pong since last ping
const timeSinceLastPong = Date.now() - this.lastPongTime;
if (timeSinceLastPong > this.heartbeatTimeoutMs + this.pongTimeoutMs) {
console.warn('WebSocket heartbeat timeout, reconnecting...');
this.ws?.close();
return;
}
this.send({ type: 'ping' });
}
}, this.heartbeatTimeoutMs);
}
private stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
@@ -236,6 +297,7 @@ export class WebSocketClient {
}
disconnect() {
this.stopHeartbeat();
this.ws?.close();
this.ws = null;
}
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import type { WriteToolInput, EditToolInput, ToolInput } from '$lib/api';
export let tool: string;
export let input: ToolInput;
export let filePath: string;
$: isWrite = tool === 'Write';
$: isEdit = tool === 'Edit';
$: writeInput = input as WriteToolInput;
$: editInput = input as EditToolInput;
function getFileExtension(path: string): string {
const match = path.match(/\.([^.]+)$/);
return match ? match[1].toLowerCase() : '';
}
function splitLines(text: string): string[] {
if (!text) return [];
return text.split('\n');
}
$: extension = getFileExtension(filePath);
</script>
<div class="diff-container bg-zinc-900 rounded-md overflow-hidden text-xs font-mono">
<!-- File header -->
<div class="diff-header bg-zinc-800 pl-1 pr-3 py-2 border-b border-zinc-700 flex items-center gap-2">
<span class="text-zinc-400">{isWrite ? 'New file' : 'Edit'}:</span>
<span class="text-zinc-200 truncate">{filePath}</span>
{#if extension}
<span class="text-zinc-500 text-[10px] uppercase bg-zinc-700 px-1.5 py-0.5 rounded"
>{extension}</span
>
{/if}
</div>
<!-- Diff content -->
<div class="diff-content overflow-x-auto max-h-80 overflow-y-auto">
{#if isWrite}
<!-- New file: show all lines as additions -->
<table class="w-full">
<tbody>
{#each splitLines(writeInput.content) as line, i}
<tr class="diff-line addition">
<td class="line-number text-zinc-600 text-right px-1 py-0 select-none w-8 border-r border-zinc-800"
>{i + 1}</td
>
<td class="line-indicator text-green-500 px-0.5 w-3">+</td>
<td class="line-content text-green-300 pr-3 whitespace-pre">{line || ' '}</td>
</tr>
{/each}
</tbody>
</table>
{:else if isEdit}
<!-- Edit: show old string as deletions, new string as additions -->
<table class="w-full">
<tbody>
<!-- Deletions (old_string) -->
{#each splitLines(editInput.old_string) as line, i}
<tr class="diff-line deletion bg-red-950/30">
<td class="line-number text-zinc-600 text-right px-1 py-0 select-none w-8 border-r border-zinc-800"
>{i + 1}</td
>
<td class="line-indicator text-red-500 px-0.5 w-3">-</td>
<td class="line-content text-red-300 pr-3 whitespace-pre">{line || ' '}</td>
</tr>
{/each}
<!-- Separator -->
{#if splitLines(editInput.old_string).length > 0 && splitLines(editInput.new_string).length > 0}
<tr class="diff-separator">
<td colspan="3" class="bg-zinc-800 h-px"></td>
</tr>
{/if}
<!-- Additions (new_string) -->
{#each splitLines(editInput.new_string) as line, i}
<tr class="diff-line addition bg-green-950/30">
<td class="line-number text-zinc-600 text-right px-1 py-0 select-none w-8 border-r border-zinc-800"
>{i + 1}</td
>
<td class="line-indicator text-green-500 px-0.5 w-3">+</td>
<td class="line-content text-green-300 pr-3 whitespace-pre">{line || ' '}</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<!-- Unknown tool type -->
<div class="p-3 text-zinc-400">
<pre class="whitespace-pre-wrap">{JSON.stringify(input, null, 2)}</pre>
</div>
{/if}
</div>
</div>
<style>
.diff-line:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.diff-line.addition:hover {
background-color: rgba(34, 197, 94, 0.15);
}
.diff-line.deletion:hover {
background-color: rgba(239, 68, 68, 0.15);
}
.line-content {
tab-size: 4;
}
</style>
+35 -4
View File
@@ -8,6 +8,12 @@
let message = '';
let textarea: HTMLTextAreaElement;
let expanded = false;
const LINE_HEIGHT = 22; // approximate line height in pixels
const PADDING = 24; // vertical padding
const MIN_HEIGHT_COLLAPSED = LINE_HEIGHT + PADDING; // 1 line
const MIN_HEIGHT_EXPANDED = LINE_HEIGHT * 4 + PADDING; // 4 lines
function handleSubmit() {
const trimmed = message.trim();
@@ -15,6 +21,7 @@
dispatch('send', trimmed);
message = '';
expanded = false;
resizeTextarea();
}
@@ -27,8 +34,16 @@
function resizeTextarea() {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
if (expanded) {
textarea.style.height = MIN_HEIGHT_EXPANDED + 'px';
} else {
textarea.style.height = MIN_HEIGHT_COLLAPSED + 'px';
}
}
function toggleExpanded() {
expanded = !expanded;
resizeTextarea();
}
export function focus() {
@@ -38,6 +53,22 @@
<form on:submit|preventDefault={handleSubmit} class="border-t border-zinc-700 bg-zinc-900 safe-bottom">
<div class="flex items-end gap-2 p-3">
<button
type="button"
on:click={toggleExpanded}
class="h-[44px] w-[44px] flex items-center justify-center text-zinc-500 hover:text-zinc-300 transition-colors flex-shrink-0"
aria-label={expanded ? 'Collapse input' : 'Expand input'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 transition-transform {expanded ? 'rotate-180' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<textarea
bind:this={textarea}
bind:value={message}
@@ -45,8 +76,8 @@
on:input={resizeTextarea}
{placeholder}
{disabled}
rows="1"
class="input resize-none min-h-[44px] max-h-[150px] py-3"
rows={expanded ? 4 : 1}
class="input resize-none py-3 {expanded ? 'min-h-[112px]' : 'min-h-[44px]'}"
></textarea>
<button
+287 -51
View File
@@ -1,13 +1,28 @@
<script lang="ts">
import type { Message } from '$lib/api';
import type { Message, PermissionDenial, ToolInput } from '$lib/api';
import { onMount, afterUpdate } from 'svelte';
import { marked } from 'marked';
import FileDiff from './FileDiff.svelte';
export let messages: Message[] = [];
export let streamingContent: string = '';
export let isThinking: boolean = false;
// 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 autoScroll = true;
let collapsedMessages: Set<string> = new Set();
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
function scrollToBottom() {
if (autoScroll && container) {
@@ -23,6 +38,34 @@
autoScroll = distanceFromBottom < threshold;
}
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
}
onMount(() => {
scrollToBottom();
});
@@ -37,11 +80,45 @@
system: 'bg-blue-500/20 border-blue-500/30 text-blue-200'
};
const roleLabels = {
user: 'You',
assistant: 'Assistant',
system: 'System'
};
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
@@ -72,63 +149,222 @@
</div>
{:else}
{#each messages as message (message.id)}
<div class="rounded-lg border p-3 {roleStyles[message.role]}">
<div class="flex items-center gap-2 mb-2">
<span
class="text-xs font-semibold uppercase tracking-wide {message.role === 'user'
? 'text-spice-400'
: 'text-zinc-400'}"
{@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}
>
<div
class="text-sm break-words font-mono leading-relaxed markdown-content {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
>
{roleLabels[message.role]}
</span>
{@html renderMarkdown(message.content)}
</div>
{#if isCollapsible}
<span
class="absolute right-3 top-3 text-zinc-500 transition-transform {isCollapsed ? '' : 'rotate-90'}"
>
</span>
{/if}
</div>
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
{message.content}
</div>
</div>
{/if}
{/each}
{#if isThinking && !streamingContent}
<div class="rounded-lg border p-3 {roleStyles.assistant}">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
Assistant
</span>
<span class="flex 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>
</span>
<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}">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
Assistant
</span>
<span class="flex 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>
</span>
</div>
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
{streamingContent}<span class="animate-pulse">|</span>
<div class="text-sm break-words font-mono leading-relaxed markdown-content">
{@html renderMarkdown(streamingContent)}<span class="animate-pulse">|</span>
</div>
</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>
@@ -1,8 +1,10 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { PermissionRequest } from '$lib/api';
import type { PermissionRequest, WriteToolInput, EditToolInput } from '$lib/api';
import FileDiff from './FileDiff.svelte';
export let permission: PermissionRequest;
export let assistantName: string = 'Assistant';
const dispatch = createEventDispatcher<{
accept: void;
@@ -10,6 +12,29 @@
steer: void;
}>();
// Track which denials are expanded
let expandedDenials: Set<number> = new Set();
function toggleDenial(index: number) {
if (expandedDenials.has(index)) {
expandedDenials.delete(index);
} else {
expandedDenials.add(index);
}
expandedDenials = expandedDenials; // trigger reactivity
}
function isFileOperation(tool: string): boolean {
return tool === 'Write' || tool === 'Edit';
}
function getFilePath(tool: string, input: unknown): string {
if ((tool === 'Write' || tool === 'Edit') && input && typeof input === 'object') {
return (input as { file_path?: string }).file_path || '';
}
return '';
}
function handleAccept() {
dispatch('accept');
}
@@ -42,13 +67,45 @@
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-amber-200">Claude needs permission:</p>
<p class="text-sm font-medium text-amber-200">{assistantName} needs permission:</p>
<ul class="mt-2 space-y-1">
{#each permission.denials as denial}
<li class="text-sm text-zinc-300 font-mono truncate">
<span class="text-amber-400">{denial.tool}:</span>
{denial.description}
<ul class="mt-2 space-y-2">
{#each permission.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={() => toggleDenial(index)}
>
<svg
class="h-4 w-4 text-zinc-500 transition-transform {expandedDenials.has(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="text-amber-400">{denial.tool}:</span>
<span class="truncate">{denial.description}</span>
</button>
{#if expandedDenials.has(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="text-amber-400">{denial.tool}:</span>
<span class="truncate">{denial.description}</span>
</div>
{/if}
</li>
{/each}
</ul>
@@ -56,19 +113,19 @@
<div class="mt-3 flex flex-wrap gap-2">
<button
on:click={handleAccept}
class="btn bg-green-600 hover:bg-green-500 text-white text-sm px-3 py-1.5"
class="btn bg-green-600 hover:bg-green-500 text-white text-sm px-3 py-1.5 rounded"
>
Accept
</button>
<button
on:click={handleDeny}
class="btn bg-red-600 hover:bg-red-500 text-white text-sm px-3 py-1.5"
class="btn bg-red-600 hover:bg-red-500 text-white text-sm px-3 py-1.5 rounded"
>
Deny
</button>
<button
on:click={handleSteer}
class="btn bg-zinc-600 hover:bg-zinc-500 text-white text-sm px-3 py-1.5"
class="btn bg-zinc-600 hover:bg-zinc-500 text-white text-sm px-3 py-1.5 rounded"
>
No, and...
</button>
+6 -7
View File
@@ -16,7 +16,6 @@
$: workingDir = session['working-dir'] || session.workingDir || '';
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
$: shortId = externalId.slice(0, 8);
$: projectName = workingDir.split('/').pop() || workingDir;
function formatTime(iso: string): string {
if (!iso) return '';
@@ -33,10 +32,10 @@
return 'Just now';
}
const statusColors = {
const statusColors: Record<string, string> = {
idle: 'bg-zinc-600',
running: 'bg-green-500 animate-pulse',
completed: 'bg-blue-500'
processing: 'bg-green-500 animate-pulse',
'awaiting-permission': 'bg-amber-500 animate-pulse'
};
const providerColors = {
@@ -62,9 +61,9 @@
{session.title || `Session ${shortId}`}
</h3>
{#if projectName}
<p class="text-sm text-zinc-400 truncate mt-1">
{projectName}
{#if workingDir}
<p class="text-sm text-zinc-400 mt-1 break-all">
{workingDir}
</p>
{/if}
</div>
@@ -0,0 +1,83 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let autoAcceptEdits: boolean = false;
export let provider: 'claude' | 'opencode' = 'claude';
const dispatch = createEventDispatcher<{
toggleAutoAccept: boolean;
}>();
let open = false;
function handleToggle() {
const newValue = !autoAcceptEdits;
dispatch('toggleAutoAccept', newValue);
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.settings-dropdown')) {
open = false;
}
}
</script>
<svelte:window on:click={handleClickOutside} />
<div class="relative settings-dropdown">
<button
on:click|stopPropagation={() => (open = !open)}
class="p-1.5 hover:bg-zinc-700 rounded transition-colors"
aria-label="Session settings"
title="Session settings"
>
<svg class="h-5 w-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
{#if open}
<div
class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl min-w-[240px] overflow-hidden z-50"
>
<div class="px-3 py-2 border-b border-zinc-700">
<span class="text-sm font-medium text-zinc-200">Session Settings</span>
</div>
{#if provider === 'claude'}
<label
class="flex items-start gap-3 px-3 py-2.5 hover:bg-zinc-700/50 cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={autoAcceptEdits}
on:change={handleToggle}
class="mt-0.5 h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-spice-500 focus:ring-spice-500 focus:ring-offset-0"
/>
<div class="flex-1 min-w-0">
<span class="text-sm text-zinc-200 block">Auto-accept edits</span>
<span class="text-xs text-zinc-500 block mt-0.5"
>Skip permission prompts for file changes</span
>
</div>
</label>
{:else}
<div class="px-3 py-2.5 text-xs text-zinc-500">
No settings available for OpenCode sessions yet.
</div>
{/if}
</div>
{/if}
</div>
+92 -5
View File
@@ -106,10 +106,13 @@ function createActiveSessionStore() {
try {
const session = await api.getSession(id);
// Load pending permission from session if it exists (persisted in DB)
const pendingPermission = session['pending-permission'] || session.pendingPermission || null;
update((s) => ({
...s,
session,
messages: session.messages || [],
pendingPermission,
loading: false
}));
@@ -174,8 +177,34 @@ function createActiveSessionStore() {
const state = get();
if (!state.session || !state.pendingPermission) return;
// Clear pending permission immediately
update((s) => ({ ...s, pendingPermission: null }));
const permission = state.pendingPermission as PermissionRequest & { 'message-id'?: string };
const messageId = permission['message-id'];
// Show loading indicator while LLM processes the permission response
// (unless user is steering, which requires them to provide a message)
const showThinking = response !== 'steer' || message;
// Update the permission message's status locally
// The server will also persist this, but we update locally for immediate feedback
if (messageId) {
update((s) => ({
...s,
messages: s.messages.map((msg) => {
if (msg.id === messageId) {
return {
...msg,
metadata: { ...msg.metadata, status: response }
};
}
return msg;
}),
pendingPermission: null,
isThinking: showThinking ? true : s.isThinking
}));
} else {
// No message-id (legacy), just clear pending permission
update((s) => ({ ...s, pendingPermission: null, isThinking: showThinking ? true : s.isThinking }));
}
try {
await api.respondToPermission(state.session.id, response, message);
@@ -201,6 +230,26 @@ function createActiveSessionStore() {
throw e;
}
},
async setAutoAcceptEdits(enabled: boolean) {
const state = get();
if (!state.session) return;
try {
const updated = await api.updateSession(state.session.id, {
'auto-accept-edits': enabled
});
update((s) => ({
...s,
session: s.session ? { ...s.session, ...updated } : null
}));
// Also update in the sessions list
sessions.updateSession(state.session.id, { 'auto-accept-edits': enabled });
return updated;
} catch (e) {
update((s) => ({ ...s, error: (e as Error).message }));
throw e;
}
},
clear() {
if (unsubscribeWs) {
unsubscribeWs();
@@ -225,6 +274,7 @@ function createActiveSessionStore() {
}
function handleStreamEvent(event: StreamEvent) {
console.log('[WS] Received event:', event.event, event);
if (event.event === 'init' && event.cwd) {
// Update session's working directory from init event
update((s) => {
@@ -234,6 +284,26 @@ function createActiveSessionStore() {
session: { ...s.session, 'working-dir': event.cwd, workingDir: event.cwd }
};
});
// Also update in the sessions list
const state = get();
if (state.session) {
sessions.updateSession(state.session.id, { 'working-dir': event.cwd });
}
} else if (event.event === 'working-dir-update' && event['working-dir']) {
// Update session's working directory when detected from tool results
const newDir = event['working-dir'];
update((s) => {
if (!s.session) return s;
return {
...s,
session: { ...s.session, 'working-dir': newDir, workingDir: newDir }
};
});
// Also update in the sessions list
const state = get();
if (state.session) {
sessions.updateSession(state.session.id, { 'working-dir': newDir });
}
} else if (event.event === 'content-delta' && event.text) {
update((s) => ({ ...s, streamingContent: s.streamingContent + event.text, isThinking: false }));
} else if (event.event === 'message-stop') {
@@ -257,8 +327,25 @@ function createActiveSessionStore() {
});
} else if (event.event === 'permission-request') {
const permReq = event['permission-request'] || event.permissionRequest;
const permMessage = (event as StreamEvent & { message?: Message }).message;
const messageId = (event as StreamEvent & { 'message-id'?: string })['message-id'];
console.log('[WS] Permission request received:', permReq, 'message:', permMessage);
if (permReq) {
update((s) => ({ ...s, pendingPermission: permReq }));
// Store the message-id in the permission request for later status update
const permReqWithMsgId = messageId ? { ...permReq, 'message-id': messageId } : permReq;
update((s) => {
// If we received the full message, add it to messages array
// Otherwise just update pendingPermission
if (permMessage) {
return {
...s,
pendingPermission: permReqWithMsgId,
messages: [...s.messages, permMessage]
};
}
return { ...s, pendingPermission: permReqWithMsgId };
});
console.log('[WS] pendingPermission state updated with message-id:', messageId);
}
} else if (event.event === 'error') {
update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false }));
@@ -278,6 +365,6 @@ export const sortedSessions: Readable<Session[]> = derived(sessions, ($sessions)
})
);
export const runningSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
$sessions.sessions.filter((s) => s.status === 'running')
export const processingSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
$sessions.sessions.filter((s) => s.status === 'processing')
);
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { sessions, sortedSessions, runningSessions } from '$lib/stores/sessions';
import { sessions, sortedSessions, processingSessions } from '$lib/stores/sessions';
import type { Session } from '$lib/api';
import SessionCard from '$lib/components/SessionCard.svelte';
@@ -111,10 +111,10 @@
</div>
</div>
{#if $runningSessions.length > 0}
{#if $processingSessions.length > 0}
<div class="mt-2 px-2 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
<span class="text-xs text-green-400">
{$runningSessions.length} session{$runningSessions.length === 1 ? '' : 's'} running
{$processingSessions.length} session{$processingSessions.length === 1 ? '' : 's'} processing
</span>
</div>
{/if}
+93 -9
View File
@@ -6,14 +6,17 @@
import MessageList from '$lib/components/MessageList.svelte';
import InputBar from '$lib/components/InputBar.svelte';
import PermissionRequest from '$lib/components/PermissionRequest.svelte';
import SessionSettings from '$lib/components/SessionSettings.svelte';
$: sessionId = $page.params.id;
let inputBar: InputBar;
let messageList: MessageList;
let steerMode = false;
let isEditingTitle = false;
let editedTitle = '';
let titleInput: HTMLInputElement;
let menuOpen = false;
onMount(() => {
if (sessionId) {
@@ -90,11 +93,17 @@
$: shortId = externalId ? externalId.slice(0, 8) : session?.id?.slice(0, 8) || '';
$: projectName = workingDir.split('/').pop() || '';
$: isNewSession = !externalId && $activeSession.messages.length === 0;
$: assistantName = session?.provider === 'opencode' ? 'OpenCode' : 'Claude';
$: autoAcceptEdits = session?.['auto-accept-edits'] || session?.autoAcceptEdits || false;
function handleToggleAutoAccept(event: CustomEvent<boolean>) {
activeSession.setAutoAcceptEdits(event.detail);
}
const statusColors: Record<string, string> = {
idle: 'bg-zinc-600',
running: 'bg-green-500 animate-pulse',
completed: 'bg-blue-500'
processing: 'bg-green-500 animate-pulse',
'awaiting-permission': 'bg-amber-500 animate-pulse'
};
</script>
@@ -102,8 +111,10 @@
<title>{session?.title || `Session ${shortId}`} - Spiceflow</title>
</svelte:head>
<!-- Header -->
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
<svelte:window on:click={() => (menuOpen = false)} />
<!-- Header - Full (portrait) -->
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3 landscape-mobile:hidden">
<div class="flex items-center gap-3">
<button
on:click={goBack}
@@ -148,6 +159,12 @@
{/if}
</div>
<SessionSettings
{autoAcceptEdits}
provider={session.provider}
on:toggleAutoAccept={handleToggleAutoAccept}
/>
<span
class="text-xs font-medium uppercase {session.provider === 'claude'
? 'text-spice-400'
@@ -159,6 +176,65 @@
</div>
</header>
<!-- Header - Collapsed (landscape mobile) -->
<div class="landscape-menu fixed top-2 right-2 z-50">
<button
on:click|stopPropagation={() => (menuOpen = !menuOpen)}
class="p-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg border border-zinc-700 transition-colors"
aria-label="Menu"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{#if menuOpen}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div on:click|stopPropagation class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl min-w-[200px] overflow-hidden">
{#if session}
<div class="px-3 py-2 border-b border-zinc-700">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full {statusColors[session.status] || statusColors.idle}"></span>
<span class="font-semibold truncate">{session.title || `Session ${shortId}`}</span>
</div>
{#if projectName}
<p class="text-xs text-zinc-500 truncate mt-0.5">{projectName}</p>
{/if}
</div>
{/if}
<button
on:click={() => { menuOpen = false; goBack(); }}
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to sessions
</button>
<button
on:click={() => { menuOpen = false; messageList?.condenseAll(); }}
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Condense all
</button>
{#if session?.provider === 'claude'}
<label class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors cursor-pointer border-t border-zinc-700">
<input
type="checkbox"
checked={autoAcceptEdits}
on:change={() => activeSession.setAutoAcceptEdits(!autoAcceptEdits)}
class="h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-spice-500 focus:ring-spice-500 focus:ring-offset-0"
/>
<span>Auto-accept edits</span>
</label>
{/if}
</div>
{/if}
</div>
<!-- Content -->
{#if $activeSession.error}
<div class="flex-1 flex items-center justify-center p-4">
@@ -181,18 +257,26 @@
</div>
{:else}
{#if workingDir}
<div class="flex-shrink-0 px-4 py-2 bg-zinc-900/50 border-b border-zinc-800 flex items-center gap-2 text-xs text-zinc-500">
<div class="flex-shrink-0 px-4 py-2 bg-zinc-900/50 border-b border-zinc-800 flex items-center gap-2 text-xs text-zinc-500 landscape-mobile:hidden">
<svg class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span class="truncate font-mono">{workingDir}</span>
<span class="flex-1"></span>
<button
on:click={() => messageList?.condenseAll()}
class="text-zinc-400 hover:text-zinc-200 transition-colors whitespace-nowrap"
>
condense all
</button>
</div>
{/if}
<MessageList messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} />
<MessageList bind:this={messageList} messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} />
{#if $activeSession.pendingPermission}
<PermissionRequest
permission={$activeSession.pendingPermission}
{assistantName}
on:accept={handlePermissionAccept}
on:deny={handlePermissionDeny}
on:steer={handlePermissionSteer}
@@ -202,10 +286,10 @@
<InputBar
bind:this={inputBar}
on:send={handleSend}
disabled={session?.status === 'running' && $activeSession.streamingContent !== ''}
disabled={session?.status === 'processing' && $activeSession.streamingContent !== ''}
placeholder={steerMode
? 'Tell Claude what to do instead...'
: session?.status === 'running'
? `Tell ${assistantName} what to do instead...`
: session?.status === 'processing'
? 'Waiting for response...'
: 'Type a message...'}
/>
+3
View File
@@ -20,6 +20,9 @@ export default {
},
fontFamily: {
mono: ['JetBrains Mono', 'Fira Code', 'monospace']
},
screens: {
'landscape-mobile': { raw: '(orientation: landscape) and (max-height: 500px)' }
}
}
},
+1 -1
View File
@@ -64,7 +64,7 @@ export default defineConfig({
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3000',
target: `http://localhost:${process.env.VITE_BACKEND_PORT || 3000}`,
changeOrigin: true,
ws: true
}