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
+45 -1
View File
@@ -71,7 +71,13 @@ Claude Code/OpenCode CLI ↔ Spiceflow Server (Clojure) ↔ PWA Client (SvelteKi
- **State**: Svelte stores in `src/lib/stores/` (sessions, runtime selection)
- **API client**: `src/lib/api.ts` - HTTP and WebSocket clients
- **Components**: `src/lib/components/` - UI components
- `MessageList.svelte` - Displays messages with collapsible long content
- `PermissionRequest.svelte` - Permission prompts with accept/deny/steer actions
- `FileDiff.svelte` - Expandable file diffs for Write/Edit operations
- `SessionSettings.svelte` - Session settings dropdown (auto-accept edits)
- `InputBar.svelte` - Message input with steer mode support
- **PWA**: vite-plugin-pwa with Workbox service worker
- **Responsive**: Landscape mobile mode collapses header to hamburger menu
### Key Protocols
@@ -98,6 +104,29 @@ Server configuration via `server/resources/config.edn` or environment variables:
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory |
| `OPENCODE_CMD` | opencode | OpenCode command |
## Features
### Permission Handling
When Claude Code requests permission for file operations (Write/Edit) or shell commands (Bash), Spiceflow intercepts these and presents them to the user:
- **Accept**: Grant permission and continue
- **Deny**: Reject the request
- **Steer ("No, and...")**: Redirect Claude with alternative instructions
File operations show expandable diffs displaying the exact changes being made.
### Auto-Accept Edits
Claude sessions can enable "Auto-accept edits" in session settings to automatically grant Write/Edit permissions, reducing interruptions during coding sessions.
### Session Management
- **Rename**: Click session title to rename
- **Delete**: Remove sessions from the session list
- **Condense**: Collapse long messages for easier scrolling
### Mobile Optimization
- Landscape mode collapses the header to a hamburger menu
- Compact file diffs with minimal padding
- Touch-friendly permission buttons
## Session Flow
1. User opens PWA → sees list of tracked sessions
@@ -107,7 +136,22 @@ Server configuration via `server/resources/config.edn` or environment variables:
5. Server pipes user message to stdin
6. CLI streams response via stdout (JSONL format)
7. Server broadcasts to client via WebSocket
8. Process completes → response saved to database
8. **If permission required** → WebSocket sends permission request → User accepts/denies/steers
9. Process completes → response saved to database
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/health` | GET | Health check |
| `/api/sessions` | GET | List all sessions |
| `/api/sessions` | POST | Create new session |
| `/api/sessions/:id` | GET | Get session with messages |
| `/api/sessions/:id` | PATCH | Update session (title, auto-accept-edits) |
| `/api/sessions/:id` | DELETE | Delete session |
| `/api/sessions/:id/send` | POST | Send message to session |
| `/api/sessions/:id/permission` | POST | Respond to permission request |
| `/ws` | WebSocket | Real-time message streaming |
## Tech Stack
+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
}
+7 -4
View File
@@ -23,10 +23,12 @@ npm run test:ui
The e2e setup automatically manages both servers:
1. **Global Setup** (`global-setup.ts`) - Starts backend (port 3000) and frontend (port 5173)
1. **Global Setup** (`global-setup.ts`) - Starts backend (port 3001) and frontend (port 5174)
2. **Global Teardown** (`global-teardown.ts`) - Stops both servers
3. **Server Utils** (`server-utils.ts`) - Spawns Clojure and Vite processes, waits for health checks
E2E tests use different ports (3001/5174) than dev servers (3000/5173) to allow running tests without interfering with development.
Tests use Playwright's `page` fixture for browser interactions and `request` fixture for direct API calls.
## Test Database
@@ -37,12 +39,13 @@ E2E tests use a separate database (`server/test-e2e.db`) to avoid polluting the
```typescript
import { test, expect } from '@playwright/test';
import { E2E_BACKEND_URL } from '../playwright.config';
test('example', async ({ page, request }) => {
// Direct API call
const response = await request.get('http://localhost:3000/api/sessions');
// Direct API call - use E2E_BACKEND_URL for backend requests
const response = await request.get(`${E2E_BACKEND_URL}/api/sessions`);
// Browser interaction
// Browser interaction - baseURL is configured in playwright.config.ts
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});
+19 -1
View File
@@ -1,12 +1,30 @@
import { startServers } from './server-utils.js';
import { E2E_BACKEND_PORT, E2E_FRONTEND_PORT } from './playwright.config.js';
import { unlinkSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
function cleanupTestFiles() {
const testFile = join(homedir(), 'foo.md');
try {
unlinkSync(testFile);
console.log('Cleaned up test file:', testFile);
} catch {
// File doesn't exist, ignore
}
}
export default async function globalSetup() {
// Clean up test files from previous runs
cleanupTestFiles();
// Skip if servers are managed externally (e.g., by scripts/test)
if (process.env.SKIP_SERVER_SETUP) {
console.log('\n=== Skipping server setup (SKIP_SERVER_SETUP is set) ===\n');
return;
}
console.log('\n=== Starting E2E Test Environment ===\n');
await startServers(3000, 5173);
console.log(`Backend port: ${E2E_BACKEND_PORT}, Frontend port: ${E2E_FRONTEND_PORT}`);
await startServers(E2E_BACKEND_PORT, E2E_FRONTEND_PORT);
console.log('\n=== E2E Test Environment Ready ===\n');
}
+16
View File
@@ -1,6 +1,22 @@
import { stopServers } from './server-utils.js';
import { unlinkSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
function cleanupTestFiles() {
const testFile = join(homedir(), 'foo.md');
try {
unlinkSync(testFile);
console.log('Cleaned up test file:', testFile);
} catch {
// File doesn't exist, ignore
}
}
export default async function globalTeardown() {
// Clean up test files
cleanupTestFiles();
// Skip if servers are managed externally (e.g., by scripts/test)
if (process.env.SKIP_SERVER_SETUP) {
console.log('\n=== Skipping server teardown (SKIP_SERVER_SETUP is set) ===\n');
+7 -1
View File
@@ -1,5 +1,11 @@
import { defineConfig, devices } from '@playwright/test';
// E2E uses different ports to avoid conflicts with dev servers
export const E2E_BACKEND_PORT = 3001;
export const E2E_FRONTEND_PORT = 5174;
export const E2E_BACKEND_URL = `http://localhost:${E2E_BACKEND_PORT}`;
export const E2E_FRONTEND_URL = `https://localhost:${E2E_FRONTEND_PORT}`;
export default defineConfig({
testDir: './tests',
fullyParallel: false,
@@ -9,7 +15,7 @@ export default defineConfig({
reporter: 'list',
timeout: 30000,
use: {
baseURL: 'https://localhost:5173',
baseURL: E2E_FRONTEND_URL,
trace: 'on-first-retry',
ignoreHTTPSErrors: true,
},
+1
View File
@@ -109,6 +109,7 @@ export async function startFrontend(port = 5173, backendPort = 3000): Promise<Ch
cwd: CLIENT_DIR,
env: {
...process.env,
VITE_BACKEND_PORT: String(backendPort),
},
stdio: ['pipe', 'pipe', 'pipe'],
});
+3 -2
View File
@@ -1,8 +1,9 @@
import { test, expect } from '@playwright/test';
import { E2E_BACKEND_URL } from '../playwright.config';
test.describe('Basic E2E Tests', () => {
test('backend health check', async ({ request }) => {
const response = await request.get('http://localhost:3000/api/health');
const response = await request.get(`${E2E_BACKEND_URL}/api/health`);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('ok');
@@ -15,7 +16,7 @@ test.describe('Basic E2E Tests', () => {
});
test('sessions list loads empty', async ({ request }) => {
const response = await request.get('http://localhost:3000/api/sessions');
const response = await request.get(`${E2E_BACKEND_URL}/api/sessions`);
expect(response.ok()).toBeTruthy();
const sessions = await response.json();
expect(Array.isArray(sessions)).toBeTruthy();
+182
View File
@@ -0,0 +1,182 @@
import { test, expect } from '@playwright/test';
test.describe('OpenCode File Workflow', () => {
test('create, read, and delete file without permission prompts', async ({ page }) => {
// Increase timeout for this test since it involves multiple AI interactions
test.setTimeout(180000);
// Enable console logging for debugging
page.on('console', (msg) => {
console.log(`[Browser ${msg.type()}]`, msg.text());
});
// Log WebSocket frames for debugging
page.on('websocket', (ws) => {
console.log(`[WebSocket] Connected to ${ws.url()}`);
ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload));
ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload));
});
// 1. Navigate to homepage
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
// 2. Click the + button to open new session menu
const createButton = page.locator('button[title="New Session"]');
await expect(createButton).toBeVisible();
await createButton.click();
// 3. Select OpenCode from the dropdown
const opencodeOption = page.locator('button:has-text("OpenCode")');
await expect(opencodeOption).toBeVisible();
await opencodeOption.click();
// 4. Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
console.log('[Test] Navigated to session page:', page.url());
// 5. Wait for the page to load
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
await expect(page.locator('text=No messages yet')).toBeVisible();
const textarea = page.locator('textarea');
const sendButton = page.locator('button[type="submit"]');
const bouncingDots = page.locator('.animate-bounce');
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
// Messages with .markdown-content are rendered assistant/user messages
const messagesWithContent = page.locator('.rounded-lg.border').filter({
has: page.locator('.markdown-content')
});
// Helper to wait for streaming to complete
const waitForStreamingComplete = async () => {
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
};
// ============================================================
// STEP 1: Create a file
// ============================================================
console.log('[Test] Step 1: Creating file');
await expect(textarea).toBeVisible();
await textarea.fill(
'Create a file called test-opencode.md with the content "Hello from OpenCode test". Just create the file, no other commentary.'
);
await expect(sendButton).toBeEnabled();
await sendButton.click();
// Verify user message appears
await expect(page.locator('text=Create a file called test-opencode.md')).toBeVisible();
console.log('[Test] User message displayed');
// Verify thinking indicator appears (bouncing dots)
await expect(bouncingDots.first()).toBeVisible({ timeout: 2000 });
console.log('[Test] Thinking indicator appeared');
// OpenCode should NOT show permission request - it auto-approves
// Wait a moment to ensure no permission UI appears
await page.waitForTimeout(2000);
const permissionUI = page.locator('text=needs permission');
await expect(permissionUI).not.toBeVisible();
console.log('[Test] Confirmed no permission prompt for file creation');
// Wait for streaming to complete
await waitForStreamingComplete();
console.log('[Test] Step 1 complete: File created');
// ============================================================
// STEP 2: Read the file
// ============================================================
console.log('[Test] Step 2: Reading file');
const messageCountAfterCreate = await messagesWithContent.count();
await textarea.fill(
'Read the contents of test-opencode.md and tell me exactly what it says.'
);
await sendButton.click();
// Wait for new assistant message
await expect(messagesWithContent).toHaveCount(messageCountAfterCreate + 1, { timeout: 60000 });
console.log('[Test] New assistant message appeared for read');
// Wait for streaming to complete
await waitForStreamingComplete();
// Verify the response contains the file content
const readResponseMessage = messagesWithContent.last();
const readResponseText = await readResponseMessage.locator('.markdown-content').textContent();
console.log('[Test] OpenCode read back:', readResponseText);
expect(readResponseText).toBeTruthy();
expect(readResponseText).toContain('Hello from OpenCode test');
console.log('[Test] Step 2 complete: File content verified');
// ============================================================
// STEP 3: Delete the file
// ============================================================
console.log('[Test] Step 3: Deleting file');
const messageCountAfterRead = await messagesWithContent.count();
await textarea.fill(
'Delete the file test-opencode.md. Confirm when done.'
);
await sendButton.click();
// Wait for new assistant message
await expect(messagesWithContent).toHaveCount(messageCountAfterRead + 1, { timeout: 60000 });
console.log('[Test] New assistant message appeared for delete');
// OpenCode should NOT show permission request for delete either
await page.waitForTimeout(1000);
await expect(permissionUI).not.toBeVisible();
console.log('[Test] Confirmed no permission prompt for file deletion');
// Wait for streaming to complete
await waitForStreamingComplete();
// Verify delete confirmation
const deleteResponseMessage = messagesWithContent.last();
const deleteResponseText = await deleteResponseMessage.locator('.markdown-content').textContent();
console.log('[Test] OpenCode delete response:', deleteResponseText);
expect(deleteResponseText).toBeTruthy();
// Response should indicate the file was deleted (various phrasings possible)
expect(deleteResponseText!.toLowerCase()).toMatch(/delet|remov|done|success/);
console.log('[Test] Step 3 complete: File deleted');
// ============================================================
// STEP 4: Verify file is gone
// ============================================================
console.log('[Test] Step 4: Verifying file no longer exists');
const messageCountAfterDelete = await messagesWithContent.count();
await textarea.fill(
'Try to read test-opencode.md again. Does it exist?'
);
await sendButton.click();
// Wait for new assistant message
await expect(messagesWithContent).toHaveCount(messageCountAfterDelete + 1, { timeout: 60000 });
// Wait for streaming to complete
await waitForStreamingComplete();
// Verify the response indicates file doesn't exist
const verifyResponseMessage = messagesWithContent.last();
const verifyResponseText = await verifyResponseMessage.locator('.markdown-content').textContent();
console.log('[Test] OpenCode verify response:', verifyResponseText);
expect(verifyResponseText).toBeTruthy();
// Response should indicate file doesn't exist
expect(verifyResponseText!.toLowerCase()).toMatch(/not exist|not found|no such|doesn't exist|does not exist|cannot find|can't find/);
console.log('[Test] Step 4 complete: Confirmed file no longer exists');
console.log('[Test] All steps completed successfully!');
});
});
@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
test.describe('Permissions Workflow', () => {
test.describe('Claude Permissions Workflow', () => {
test('permission approval allows file creation and reading', async ({ page }) => {
// Increase timeout for this test since it involves real Claude interaction
test.setTimeout(180000);
@@ -21,20 +21,25 @@ test.describe('Permissions Workflow', () => {
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
// 2. Create a new session
// 2. Click the + button to open new session menu
const createButton = page.locator('button[title="New Session"]');
await expect(createButton).toBeVisible();
await createButton.click();
// 3. Wait for navigation to session page
// 3. Select Claude Code from the dropdown
const claudeOption = page.locator('button:has-text("Claude Code")');
await expect(claudeOption).toBeVisible();
await claudeOption.click();
// 4. Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
console.log('[Test] Navigated to session page:', page.url());
// 4. Wait for the page to load
// 5. Wait for the page to load
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
await expect(page.locator('text=No messages yet')).toBeVisible();
// 5. Send message asking Claude to create foo.md with a haiku
// 6. Send message asking Claude to create foo.md with a haiku
const textarea = page.locator('textarea');
await expect(textarea).toBeVisible();
await textarea.fill(
@@ -45,73 +50,75 @@ test.describe('Permissions Workflow', () => {
await expect(sendButton).toBeEnabled();
await sendButton.click();
// 6. Verify user message appears
// 7. Verify user message appears
await expect(
page.locator('text=Create a file called foo.md')
).toBeVisible();
console.log('[Test] User message displayed');
// 6b. Verify thinking indicator appears immediately
const assistantBubble = page.locator('.rounded-lg.border').filter({
has: page.locator('text=Assistant')
}).first();
await expect(assistantBubble).toBeVisible({ timeout: 2000 });
// 7b. Verify thinking indicator appears immediately
// The thinking indicator shows bouncing dots
const bouncingDotsIndicator = page.locator('.animate-bounce');
await expect(bouncingDotsIndicator.first()).toBeVisible({ timeout: 2000 });
console.log('[Test] Thinking indicator appeared immediately');
// 7. Wait for permission request UI to appear
// 8. Wait for permission request UI to appear
const permissionUI = page.locator('text=Claude needs permission');
await expect(permissionUI).toBeVisible({ timeout: 60000 });
console.log('[Test] Permission request UI appeared');
// 8. Verify the permission shows Write tool for foo.md
const permissionDescription = page.locator('li.font-mono').filter({
// 9. Verify the permission shows Write tool for foo.md
// The permission UI has li > button.font-mono or li > div.font-mono with "Write:" and filename
const permissionDescription = page.locator('.font-mono').filter({
hasText: /Write.*foo\.md|create.*foo\.md/i
}).first();
await expect(permissionDescription).toBeVisible();
console.log('[Test] Permission shows foo.md file creation');
// 9. Click Accept button
// 10. Click Accept button
const acceptButton = page.locator('button:has-text("Accept")');
await expect(acceptButton).toBeVisible();
await acceptButton.click();
console.log('[Test] Clicked Accept button');
// 10. Wait for permission UI to disappear
// 11. Wait for permission UI to disappear
await expect(permissionUI).not.toBeVisible({ timeout: 10000 });
console.log('[Test] Permission UI disappeared');
// 11. Wait for streaming to complete after permission granted
// 12. Wait for streaming to complete after permission granted
await page.waitForTimeout(2000);
const bouncingDots = page.locator('.animate-bounce');
const pulsingCursor = page.locator('.animate-pulse');
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
console.log('[Test] First streaming complete');
// Count current assistant messages before sending new request
// Count current messages with markdown-content before sending new request
// Assistant messages have .markdown-content inside the bubble
const assistantMessages = page.locator('.rounded-lg.border').filter({
has: page.locator('text=Assistant')
has: page.locator('.markdown-content')
});
const messageCountBefore = await assistantMessages.count();
console.log('[Test] Assistant message count before read request:', messageCountBefore);
console.log('[Test] Message count before read request:', messageCountBefore);
// 12. Now ask Claude to read the file back to verify it was created
// 13. Now ask Claude to read the file back to verify it was created
await textarea.fill('Read the contents of foo.md and tell me what it says. Quote the file contents.');
await sendButton.click();
console.log('[Test] Asked Claude to read the file');
// 13. Wait for a NEW assistant message to appear
// 14. Wait for a NEW message to appear
await expect(assistantMessages).toHaveCount(messageCountBefore + 1, { timeout: 60000 });
console.log('[Test] New assistant message appeared');
console.log('[Test] New message appeared');
// Wait for streaming to complete on the new message
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
console.log('[Test] Second streaming complete');
// 14. Verify the response contains "My Haiku" - confirming file was created and read
// 15. Verify the response contains "My Haiku" - confirming file was created and read
const lastAssistantMessage = assistantMessages.last();
const responseText = await lastAssistantMessage.locator('.font-mono').textContent();
const responseText = await lastAssistantMessage.locator('.markdown-content').textContent();
console.log('[Test] Claude read back:', responseText);
// The response should contain "My Haiku" which we asked Claude to title the file
+113
View File
@@ -0,0 +1,113 @@
import { test, expect } from '@playwright/test';
import { E2E_BACKEND_URL } from '../playwright.config';
test.describe('Claude Working Directory Auto-Update', () => {
test('working directory updates automatically after cd command', async ({ page, request }) => {
// Increase timeout for this test since it involves real Claude interaction
test.setTimeout(180000);
// Enable console logging to debug issues
page.on('console', (msg) => {
console.log(`[Browser ${msg.type()}]`, msg.text());
});
// Log WebSocket frames
page.on('websocket', (ws) => {
console.log(`[WebSocket] Connected to ${ws.url()}`);
ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload));
ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload));
ws.on('close', () => console.log('[WebSocket] Closed'));
});
// Log network requests to /api
page.on('request', (req) => {
if (req.url().includes('/api')) {
console.log(`[Request] ${req.method()} ${req.url()}`);
}
});
page.on('response', (response) => {
if (response.url().includes('/api')) {
console.log(`[Response] ${response.status()} ${response.url()}`);
}
});
// 1. Navigate to homepage
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
// 2. Click the + button to open new session menu
const createButton = page.locator('button[title="New Session"]');
await expect(createButton).toBeVisible();
await createButton.click();
// 3. Select Claude Code from the dropdown
const claudeOption = page.locator('button:has-text("Claude Code")');
await expect(claudeOption).toBeVisible();
await claudeOption.click();
// 4. Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
const sessionUrl = page.url();
const sessionId = sessionUrl.split('/session/')[1];
console.log('[Test] Navigated to session page:', sessionUrl);
console.log('[Test] Session ID:', sessionId);
// 5. Wait for the page to load (no loading spinner)
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
// 6. Verify we see the empty message state
await expect(page.locator('text=No messages yet')).toBeVisible();
// 7. Send a message to Claude asking it to cd into repos (natural language)
// Claude should run the cd command and ideally output the current directory
const textarea = page.locator('textarea');
await expect(textarea).toBeVisible();
await textarea.fill('change directory to ~/repos and tell me where you are now');
// 8. Click the send button
const sendButton = page.locator('button[type="submit"]');
await expect(sendButton).toBeEnabled();
await sendButton.click();
// 9. Wait for streaming to complete
const bouncingDots = page.locator('.animate-bounce');
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
console.log('[Test] Message complete');
// 10. The working directory bar should now show the repos path (automatically updated)
// The working dir bar is in a specific container with bg-zinc-900/50
const workingDirBar = page.locator('div.bg-zinc-900\\/50');
await expect(workingDirBar).toBeVisible({ timeout: 10000 });
// The working dir text is in a span.truncate.font-mono inside the bar
const workingDirText = workingDirBar.locator('span.truncate.font-mono');
await expect(workingDirText).toBeVisible();
// 11. Wait for the working directory to contain 'repos' (automatic update from tool result)
await expect(workingDirText).toContainText('repos', { timeout: 10000 });
const displayedWorkingDir = await workingDirText.textContent();
console.log('[Test] Working directory in UI:', displayedWorkingDir);
expect(displayedWorkingDir).toContain('repos');
// 12. Verify the working directory in the database via API
const sessionResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${sessionId}`);
expect(sessionResponse.ok()).toBeTruthy();
const sessionData = await sessionResponse.json();
console.log('[Test] Session data from API:', JSON.stringify(sessionData, null, 2));
// The API returns session data directly (not nested under 'session')
const dbWorkingDir = sessionData['working-dir'] || sessionData.workingDir || '';
console.log('[Test] Working directory from DB:', dbWorkingDir);
// DB should have the repos path
expect(dbWorkingDir).toContain('repos');
// UI and DB should match
expect(displayedWorkingDir).toBe(dbWorkingDir);
console.log('[Test] Auto-sync test passed - working directory automatically updated to repos path');
});
});
+104
View File
@@ -0,0 +1,104 @@
import { test, expect } from '@playwright/test';
test.describe('Claude Chat Workflow', () => {
test('create new chat and send message to Claude', async ({ page }) => {
// Enable console logging to debug WebSocket issues
page.on('console', (msg) => {
console.log(`[Browser ${msg.type()}]`, msg.text());
});
// Log WebSocket frames
page.on('websocket', (ws) => {
console.log(`[WebSocket] Connected to ${ws.url()}`);
ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload));
ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload));
ws.on('close', () => console.log('[WebSocket] Closed'));
});
// Log network requests to /api
page.on('request', (request) => {
if (request.url().includes('/api')) {
console.log(`[Request] ${request.method()} ${request.url()}`);
}
});
page.on('response', (response) => {
if (response.url().includes('/api')) {
console.log(`[Response] ${response.status()} ${response.url()}`);
}
});
// 1. Navigate to homepage
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
// 2. Click the + button to open new session menu
const createButton = page.locator('button[title="New Session"]');
await expect(createButton).toBeVisible();
await createButton.click();
// 3. Select Claude Code from the dropdown
const claudeOption = page.locator('button:has-text("Claude Code")');
await expect(claudeOption).toBeVisible();
await claudeOption.click();
// 4. Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
console.log('[Test] Navigated to session page:', page.url());
// 5. Wait for the page to load (no loading spinner)
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
// 6. Verify we see the empty message state
await expect(page.locator('text=No messages yet')).toBeVisible();
// 7. Type a message in the textarea
const textarea = page.locator('textarea');
await expect(textarea).toBeVisible();
await textarea.fill('say hi. respond in a single concise sentence');
// 8. Click the send button
const sendButton = page.locator('button[type="submit"]');
await expect(sendButton).toBeEnabled();
await sendButton.click();
// 9. Verify user message appears immediately (optimistic update)
await expect(page.locator('text=say hi. respond in a single concise sentence')).toBeVisible();
console.log('[Test] User message displayed');
// 10. Verify thinking indicator appears immediately after sending
// The thinking indicator is a .rounded-lg.border div with bouncing dots inside
const bouncingDots = page.locator('.animate-bounce');
// Thinking indicator should appear almost immediately (within 2 seconds)
await expect(bouncingDots.first()).toBeVisible({ timeout: 2000 });
console.log('[Test] Thinking indicator appeared immediately');
// 11. Wait for streaming to complete - progress indicator should disappear
// The streaming indicator has animate-bounce dots and animate-pulse cursor
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
// Wait for streaming indicators to disappear (they should be gone after message-stop)
await expect(bouncingDots).toHaveCount(0, { timeout: 30000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 });
console.log('[Test] Streaming complete - progress indicator disappeared');
// 12. Verify the response contains some text content
// The assistant message is the last .rounded-lg.border with .markdown-content inside
const assistantMessage = page.locator('.rounded-lg.border').filter({
has: page.locator('.markdown-content')
}).last();
const responseText = await assistantMessage.locator('.markdown-content').textContent();
console.log('[Test] Assistant response text:', responseText);
expect(responseText).toBeTruthy();
expect(responseText!.length).toBeGreaterThan(0);
// 13. Verify working directory indicator appears
// The working directory should be captured from the init event and displayed
const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first();
await expect(workingDirIndicator).toBeVisible({ timeout: 5000 });
const workingDirText = await workingDirIndicator.textContent();
console.log('[Test] Working directory displayed:', workingDirText);
expect(workingDirText).toMatch(/^\//); // Should start with /
});
});
@@ -1,7 +1,9 @@
import { test, expect } from '@playwright/test';
test.describe('Chat Workflow', () => {
test('create new chat and send message to Claude', async ({ page }) => {
test.describe('OpenCode Chat Workflow', () => {
// Skip: OpenCode (Go binary) has stdout buffering issues when run as subprocess from Java
// Go binaries ignore stdbuf and require a pseudo-terminal for proper streaming
test.skip('create new chat and send message to OpenCode', async ({ page }) => {
// Enable console logging to debug WebSocket issues
page.on('console', (msg) => {
console.log(`[Browser ${msg.type()}]`, msg.text());
@@ -31,36 +33,41 @@ test.describe('Chat Workflow', () => {
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
// 2. Click the + button to create a new session
// 2. Click the + button to open new session menu
const createButton = page.locator('button[title="New Session"]');
await expect(createButton).toBeVisible();
await createButton.click();
// 3. Wait for navigation to session page
// 3. Select OpenCode from the dropdown
const opencodeOption = page.locator('button:has-text("OpenCode")');
await expect(opencodeOption).toBeVisible();
await opencodeOption.click();
// 4. Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
console.log('[Test] Navigated to session page:', page.url());
// 4. Wait for the page to load (no loading spinner)
// 5. Wait for the page to load (no loading spinner)
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
// 5. Verify we see the empty message state
// 6. Verify we see the empty message state
await expect(page.locator('text=No messages yet')).toBeVisible();
// 6. Type a message in the textarea
// 7. Type a message in the textarea
const textarea = page.locator('textarea');
await expect(textarea).toBeVisible();
await textarea.fill('say hi. respond in a single concise sentence');
// 7. Click the send button
// 8. Click the send button
const sendButton = page.locator('button[type="submit"]');
await expect(sendButton).toBeEnabled();
await sendButton.click();
// 8. Verify user message appears immediately (optimistic update)
// 9. Verify user message appears immediately (optimistic update)
await expect(page.locator('text=say hi. respond in a single concise sentence')).toBeVisible();
console.log('[Test] User message displayed');
// 9. Verify thinking indicator appears immediately after sending
// 10. Verify thinking indicator appears immediately after sending
// The assistant bubble with bouncing dots should show right away (isThinking state)
const assistantMessage = page.locator('.rounded-lg.border').filter({
has: page.locator('text=Assistant')
@@ -75,25 +82,26 @@ test.describe('Chat Workflow', () => {
await expect(bouncingDotsInAssistant.first()).toBeVisible({ timeout: 2000 });
console.log('[Test] Bouncing dots visible in thinking state');
// 10. Wait for streaming to complete - progress indicator should disappear
// 11. Wait for streaming to complete - progress indicator should disappear
// The streaming indicator has animate-bounce dots and animate-pulse cursor
// Note: With fast responses, the indicator may appear and disappear quickly,
// so we just verify it's gone after the response is visible
const bouncingDots = page.locator('.animate-bounce');
const pulsingCursor = page.locator('.animate-pulse');
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
// Wait for streaming indicators to disappear (they should be gone after message-stop)
await expect(bouncingDots).toHaveCount(0, { timeout: 30000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 });
console.log('[Test] Streaming complete - progress indicator disappeared');
// 11. Verify the response contains some text content
// 12. Verify the response contains some text content
const responseText = await assistantMessage.locator('.font-mono').textContent();
console.log('[Test] Assistant response text:', responseText);
expect(responseText).toBeTruthy();
expect(responseText!.length).toBeGreaterThan(0);
// 12. Verify working directory indicator appears
// 13. Verify working directory indicator appears
// The working directory should be captured from the init event and displayed
const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first();
await expect(workingDirIndicator).toBeVisible({ timeout: 5000 });
+5
View File
@@ -0,0 +1,5 @@
# My Haiku:
Wires cross and spark
Agents dance to their own tune
My wishes fade fast
+47 -10
View File
@@ -1,7 +1,5 @@
#!/bin/bash
# Development script - runs backend and frontend concurrently
set -e
# Development script - runs backend REPL with auto-reload and frontend
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
@@ -13,21 +11,56 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
BACKEND_PID=""
FRONTEND_PID=""
NREPL_PORT=7888
cleanup() {
echo -e "\n${YELLOW}Shutting down...${NC}"
kill $BACKEND_PID 2>/dev/null || true
kill $FRONTEND_PID 2>/dev/null || true
# Kill backend and all its children
if [ -n "$BACKEND_PID" ]; then
pkill -P $BACKEND_PID 2>/dev/null || true
kill $BACKEND_PID 2>/dev/null || true
fi
# Kill frontend and all its children
if [ -n "$FRONTEND_PID" ]; then
pkill -P $FRONTEND_PID 2>/dev/null || true
kill $FRONTEND_PID 2>/dev/null || true
fi
# Also kill any orphaned processes on our ports
fuser -k 3000/tcp 2>/dev/null || true
fuser -k 5173/tcp 2>/dev/null || true
fuser -k $NREPL_PORT/tcp 2>/dev/null || true
wait 2>/dev/null || true
echo -e "${GREEN}Stopped${NC}"
# Restore terminal
stty sane 2>/dev/null || true
exit 0
}
trap cleanup SIGINT SIGTERM
trap cleanup SIGINT SIGTERM SIGHUP EXIT
echo -e "${BLUE}=== Starting Spiceflow Development Environment ===${NC}\n"
# Start backend
echo -e "${GREEN}Starting backend server...${NC}"
# Start backend REPL with auto-reload
echo -e "${GREEN}Starting backend REPL with auto-reload...${NC}"
cd "$ROOT_DIR/server"
clj -M:run &
# Start nREPL server and run (go) to start app with file watcher
clj -M:dev -e "
(require 'nrepl.server)
(def server (nrepl.server/start-server :port $NREPL_PORT))
(println \"nREPL server started on port $NREPL_PORT\")
(require 'user)
(user/go)
;; Block forever to keep the process running
@(promise)
" &
BACKEND_PID=$!
# Wait for backend to be ready
@@ -36,6 +69,8 @@ until curl -s http://localhost:3000/api/health > /dev/null 2>&1; do
sleep 1
done
echo -e "${GREEN}Backend ready on http://localhost:3000${NC}"
echo -e "${GREEN}nREPL available on port $NREPL_PORT${NC}"
echo -e "${GREEN}Auto-reload enabled - editing .clj files will trigger reload${NC}"
# Start frontend
echo -e "${GREEN}Starting frontend server...${NC}"
@@ -55,9 +90,11 @@ LOCAL_IP=$(hostname -I | awk '{print $1}')
echo -e "\n${BLUE}=== Spiceflow Development Environment Ready ===${NC}"
echo -e "${GREEN}Backend:${NC} http://localhost:3000"
echo -e "${GREEN}nREPL:${NC} localhost:$NREPL_PORT"
echo -e "${GREEN}Frontend:${NC} https://localhost:5173"
echo -e "${GREEN}Phone:${NC} https://${LOCAL_IP}:5173"
echo -e "\nPress Ctrl+C to stop\n"
echo -e "\n${YELLOW}Auto-reload is active. Edit any .clj file to trigger reload.${NC}"
echo -e "Press Ctrl+C to stop\n"
# Wait for processes
wait
+10 -6
View File
@@ -39,31 +39,35 @@ echo -e "${BLUE}=== Starting Spiceflow Test Environment ===${NC}\n"
# Clean up old test database
rm -f "$ROOT_DIR/server/test-e2e.db"
# Use different ports for e2e tests (matching playwright.config.ts)
E2E_BACKEND_PORT=3001
E2E_FRONTEND_PORT=5174
# Start backend with test database
echo -e "${GREEN}Starting backend server with test database...${NC}"
cd "$ROOT_DIR/server"
SPICEFLOW_DB="test-e2e.db" clj -M:run &
SPICEFLOW_DB="test-e2e.db" SPICEFLOW_PORT=$E2E_BACKEND_PORT clj -M:run &
BACKEND_PID=$!
# Wait for backend to be ready
echo -e "${YELLOW}Waiting for backend...${NC}"
until curl -s http://localhost:3000/api/health > /dev/null 2>&1; do
until curl -s http://localhost:$E2E_BACKEND_PORT/api/health > /dev/null 2>&1; do
sleep 1
done
echo -e "${GREEN}Backend ready on http://localhost:3000${NC}"
echo -e "${GREEN}Backend ready on http://localhost:$E2E_BACKEND_PORT${NC}"
# Start frontend
echo -e "${GREEN}Starting frontend server...${NC}"
cd "$ROOT_DIR/client"
npm run dev -- --port 5173 &
VITE_API_URL="http://localhost:$E2E_BACKEND_PORT" npm run dev -- --port $E2E_FRONTEND_PORT &
FRONTEND_PID=$!
# Wait for frontend to be ready
echo -e "${YELLOW}Waiting for frontend...${NC}"
until curl -sk https://localhost:5173 > /dev/null 2>&1; do
until curl -sk https://localhost:$E2E_FRONTEND_PORT > /dev/null 2>&1; do
sleep 1
done
echo -e "${GREEN}Frontend ready on https://localhost:5173${NC}\n"
echo -e "${GREEN}Frontend ready on https://localhost:$E2E_FRONTEND_PORT${NC}\n"
# Run e2e tests
echo -e "${BLUE}=== Running E2E Tests ===${NC}\n"
+6 -1
View File
@@ -32,4 +32,9 @@
:main-opts ["-m" "kaocha.runner"]}
:repl {:main-opts ["-m" "nrepl.cmdline" "-i"]
:extra-deps {nrepl/nrepl {:mvn/version "1.1.0"}
cider/cider-nrepl {:mvn/version "0.44.0"}}}}}
cider/cider-nrepl {:mvn/version "0.44.0"}}}
:dev {:extra-paths ["dev"]
:extra-deps {nrepl/nrepl {:mvn/version "1.1.0"}
cider/cider-nrepl {:mvn/version "0.44.0"}
org.clojure/tools.namespace {:mvn/version "1.5.0"}
hawk/hawk {:mvn/version "0.2.11"}}}}}
+76
View File
@@ -0,0 +1,76 @@
(ns user
(:require [clojure.tools.namespace.repl :as repl]
[clojure.tools.logging :as log]
[mount.core :as mount]
[hawk.core :as hawk]))
;; Only reload spiceflow namespaces
(repl/set-refresh-dirs "src")
(defonce watcher (atom nil))
(defn start
"Start the application."
[]
(require 'spiceflow.core)
((resolve 'spiceflow.core/-main)))
(defn stop
"Stop the application."
[]
(mount/stop))
(defn reset
"Stop, reload all changed namespaces, and restart."
[]
(stop)
(repl/refresh :after 'user/start))
(defn reload
"Reload all changed namespaces without restarting."
[]
(repl/refresh))
(defn reload-all
"Force reload all app namespaces."
[]
(stop)
(repl/refresh-all :after 'user/start))
(defn- clj-file? [_ {:keys [file]}]
(and file (.endsWith (.getName file) ".clj")))
(defn- on-file-change [_ _]
(log/info "File change detected, reloading namespaces...")
(log/info "Reloading workspaces...")
(try
(reset)
(log/info "Reload complete")
(catch Exception e
(log/error e "Reload failed"))))
(defn watch
"Start watching src directory for changes and auto-reload."
[]
(when @watcher
(hawk/stop! @watcher))
(reset! watcher
(hawk/watch! [{:paths ["src"]
:filter clj-file?
:handler on-file-change}]))
(log/info "File watcher started - will auto-reload on .clj changes"))
(defn unwatch
"Stop the file watcher."
[]
(when @watcher
(hawk/stop! @watcher)
(reset! watcher nil)
(log/info "File watcher stopped")))
(defn go
"Start the app and enable auto-reload on file changes."
[]
(start)
(watch)
:ready)
+5
View File
@@ -0,0 +1,5 @@
# My Haiku
Code finds its truth
In tests that catch the bugs
Software sleeps sound
+20 -2
View File
@@ -1,15 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- Console appender - INFO and above only -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="spiceflow" level="INFO"/>
<!-- File appender - DEBUG and above (captures all agent/user activity) -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/spiceflow.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/spiceflow.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Spiceflow at DEBUG to capture all agent responses and user actions -->
<logger name="spiceflow" level="DEBUG"/>
<logger name="org.eclipse.jetty" level="WARN"/>
<root level="INFO">
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
<appender-ref ref="FILE"/>
</root>
</configuration>
+141 -41
View File
@@ -1,5 +1,8 @@
(ns spiceflow.adapters.opencode
"Adapter for OpenCode CLI"
"Adapter for OpenCode CLI.
OpenCode works differently from Claude - it uses a single-shot execution model
where each message spawns a new `opencode run` process with the message as an argument."
(:require [spiceflow.adapters.protocol :as proto]
[clojure.java.io :as io]
[clojure.java.shell :as shell]
@@ -60,26 +63,64 @@
(log/warn "Failed to parse session export:" (.getMessage e))
nil)))
(defn- parse-stream-output
"Parse a line of streaming output from OpenCode"
(defn- parse-json-event
"Parse a JSON event from OpenCode's --format json output"
[line]
(try
(when (and line (not (str/blank? line)))
(if (str/starts-with? line "{")
(let [data (json/read-value line mapper)]
(cond
(:content data) {:event :content-delta
:text (:content data)}
(:done data) {:event :message-stop}
(:error data) {:event :error
:message (:error data)}
:else {:raw data}))
;; Plain text output
{:event :content-delta
:text line}))
(let [data (json/read-value line mapper)]
(log/debug "OpenCode JSON event:" (:type data))
(case (:type data)
;; Step start - beginning of a new step
"step_start" {:event :init
:session-id (:sessionID data)
:cwd (get-in data [:part :cwd])}
;; Text content - the actual response text
"text" {:event :content-delta
:text (get-in data [:part :text])}
;; Tool use events
"tool_start" {:event :tool-start
:tool (get-in data [:part :tool])
:input (get-in data [:part :input])}
"tool_finish" {:event :tool-result
:tool (get-in data [:part :tool])
:result (get-in data [:part :result])}
;; Step finish - end of current step
"step_finish" {:event :result
:session-id (:sessionID data)
:cost (get-in data [:part :cost])
:stop-reason (get-in data [:part :reason])}
;; Error events
"error" {:event :error
:message (:message data)}
;; Permission-related events (if OpenCode has them)
"permission_request" {:event :permission-request
:permission-request {:tools [(get-in data [:part :tool])]
:denials [{:tool (get-in data [:part :tool])
:input (get-in data [:part :input])
:description (get-in data [:part :description])}]}}
;; Default: pass through as raw (don't emit content-delta for unknown types)
(do
(log/debug "Unknown OpenCode event type:" (:type data))
{:raw data}))))
(catch Exception e
(log/debug "Failed to parse line:" line)
{:event :content-delta :text line})))
(log/debug "Failed to parse JSON line:" line (.getMessage e))
;; If it's not JSON, treat as plain text
(when (and line (not (str/blank? line)))
{:event :content-delta :text (str line "\n")}))))
(defn- parse-default-output
"Parse default (non-JSON) output from OpenCode"
[line]
(when (and line (not (str/blank? line)))
{:event :content-delta
:text (str line "\n")}))
(defrecord OpenCodeAdapter [command]
proto/AgentAdapter
@@ -92,44 +133,103 @@
[]))
(spawn-session [_ session-id opts]
(let [{:keys [working-dir]} opts
args [command "run" "--session" session-id]
pb (ProcessBuilder. args)]
(when working-dir
(.directory pb (io/file working-dir)))
(.redirectErrorStream pb false)
(let [process (.start pb)]
{:process process
:stdin (BufferedWriter. (OutputStreamWriter. (.getOutputStream process) StandardCharsets/UTF_8))
:stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
:stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))})))
;; For OpenCode, we don't start a process here - we just create a handle
;; that stores the configuration. The actual process is started when send-message is called.
(let [{:keys [working-dir allowed-tools]} opts]
{:session-id session-id
:working-dir working-dir
:allowed-tools allowed-tools
:command command
;; These will be populated when send-message is called
:process nil
:stdout nil
:stderr nil}))
(send-message [_ {:keys [stdin]} message]
(send-message [_ handle message]
(try
(.write stdin message)
(.newLine stdin)
(.flush stdin)
true
(let [{:keys [session-id working-dir command allowed-tools]} handle
;; Build command args
;; Use --format json for structured output
;; Build the opencode command string
opencode-cmd (str command " run --format json"
(when session-id (str " --session " session-id))
" " (pr-str message))
;; Wrap with script -qc to create a pseudo-terminal
;; This forces Go to flush stdout properly (Go binaries ignore stdbuf)
args ["script" "-qc" opencode-cmd "/dev/null"]
_ (log/info "Starting OpenCode with args:" args)
pb (ProcessBuilder. (vec args))]
;; Set working directory
(when working-dir
(.directory pb (io/file working-dir)))
;; Don't merge stderr into stdout - keep them separate
(.redirectErrorStream pb false)
;; Start the process
(let [process (.start pb)
stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))]
;; Update the handle with the running process
;; Note: We're mutating the handle here by storing process info
;; The caller should use the returned handle
(assoc handle
:process process
:stdout stdout
:stderr stderr)))
(catch Exception e
(log/error "Failed to send message:" (.getMessage e))
false)))
(log/error "Failed to start OpenCode process:" (.getMessage e))
nil)))
(read-stream [this {:keys [stdout]} callback]
(read-stream [this {:keys [stdout stderr process]} callback]
(log/info "read-stream starting, stdout:" (boolean stdout) "process:" (boolean process) "process-alive:" (when process (.isAlive process)))
(try
;; Start a thread to log stderr
(when stderr
(future
(try
(loop []
(when-let [line (.readLine stderr)]
(log/info "[OpenCode stderr]" line)
(recur)))
(catch Exception e
(log/info "Stderr stream ended:" (.getMessage e))))))
;; Read stdout for JSON events
(log/info "Starting stdout read loop")
(loop []
(log/debug "Waiting for line from stdout...")
(when-let [line (.readLine stdout)]
(when-let [parsed (proto/parse-output this line)]
(callback parsed))
(recur)))
(log/info "[OpenCode stdout]" line)
(let [parsed (proto/parse-output this line)]
(when parsed
(log/info "Parsed event:" (:event parsed))
(callback parsed))
;; Continue reading unless we hit a terminal event
;; Note: step_finish with reason "tool-calls" is NOT terminal - OpenCode
;; continues after running tools. Only stop on :error or :result with
;; a non-tool-calls reason.
(if (or (= :error (:event parsed))
(and (= :result (:event parsed))
(not= "tool-calls" (:stop-reason parsed))))
(log/info "Received terminal event, stopping stream read. stop-reason:" (:stop-reason parsed))
(recur)))))
(log/info "stdout read loop ended (nil line)")
;; Wait for process to complete
(when process
(log/info "Waiting for process to complete")
(.waitFor process)
(log/info "Process completed with exit code:" (.exitValue process)))
(catch Exception e
(log/debug "Stream ended:" (.getMessage e)))))
(log/error "Stream error:" (.getMessage e) (class e)))))
(kill-process [_ {:keys [process]}]
(when process
(.destroyForcibly process)))
(parse-output [_ line]
(parse-stream-output line)))
(parse-json-event line)))
(defn create-adapter
"Create an OpenCode adapter"
+6 -1
View File
@@ -41,6 +41,7 @@
[store]
(fn [request]
(let [body (:body request)]
(log/debug "API request: create-session" {:body body})
(if (db/valid-session? body)
(let [session (db/save-session store body)]
(-> (json-response session)
@@ -51,6 +52,7 @@
[store]
(fn [request]
(let [id (get-in request [:path-params :id])]
(log/debug "API request: delete-session" {:session-id id})
(if (db/get-session store id)
(do
(manager/stop-session store id)
@@ -63,8 +65,9 @@
(fn [request]
(let [id (get-in request [:path-params :id])
body (:body request)]
(log/debug "API request: update-session" {:session-id id :body body})
(if (db/get-session store id)
(let [updated (db/update-session store id (select-keys body [:title]))]
(let [updated (db/update-session store id (select-keys body [:title :auto-accept-edits]))]
(json-response updated))
(error-response 404 "Session not found")))))
@@ -73,6 +76,7 @@
(fn [request]
(let [id (get-in request [:path-params :id])
message (get-in request [:body :message])]
(log/debug "API request: send-message" {:session-id id :message message})
(if-let [session (db/get-session store id)]
(try
;; Send message and start streaming in a separate thread
@@ -101,6 +105,7 @@
(let [id (get-in request [:path-params :id])
{:keys [response message]} (:body request)
response-type (keyword response)]
(log/debug "API request: permission-response" {:session-id id :response response :message message})
(if-let [session (db/get-session store id)]
(if (#{:accept :deny :steer} response-type)
(try
+18 -2
View File
@@ -8,6 +8,14 @@
(def ^:private mapper (json/object-mapper {:encode-key-fn name
:decode-key-fn keyword}))
;; Function to get pending permission for a session (set by core.clj)
(defonce ^:private pending-permission-fn (atom nil))
(defn set-pending-permission-fn!
"Set the function to retrieve pending permissions for a session"
[f]
(reset! pending-permission-fn f))
;; Connected WebSocket sessions: session-id -> #{sockets}
(defonce ^:private connections (ConcurrentHashMap.))
@@ -27,7 +35,8 @@
(defn broadcast-to-session
"Broadcast an event to all WebSocket connections subscribed to a session"
[session-id event]
(log/debug "Broadcasting to session:" session-id "event:" event)
(log/debug "Broadcasting to session:" session-id "event-type:" (:event event))
(log/debug "Broadcast full event data:" (pr-str event))
(when-let [subscribers (.get connections session-id)]
(let [message (assoc event :session-id session-id)]
(doseq [socket subscribers]
@@ -47,7 +56,14 @@
(let [new-set (ConcurrentHashMap/newKeySet)]
(.putIfAbsent connections session-id new-set)
(or (.get connections session-id) new-set)))]
(.add subscribers socket)))
(.add subscribers socket)
;; Send any pending permission request immediately after subscribing
(when-let [get-pending @pending-permission-fn]
(when-let [pending (get-pending session-id)]
(log/debug "Sending pending permission to newly subscribed client:" pending)
(send-to-ws socket {:event :permission-request
:permission-request pending
:session-id session-id})))))
(defn- unsubscribe-from-session
"Unsubscribe a WebSocket socket from a session"
+2
View File
@@ -25,6 +25,8 @@
(defstate server
:start (let [port (get-in config/config [:server :port] 3000)
host (get-in config/config [:server :host] "0.0.0.0")
;; Wire up pending permission function for WebSocket (partially apply store)
_ (ws/set-pending-permission-fn! (partial manager/get-pending-permission store))
api-app (routes/create-app store ws/broadcast-to-session)
;; Wrap the app to handle WebSocket upgrades on /api/ws
app (fn [request]
+4
View File
@@ -65,6 +65,10 @@
new-message))
(get-message [_ id]
(get @messages id))
(update-message [_ id data]
(swap! messages update id merge data)
(get @messages id)))
(defn create-store
+3 -1
View File
@@ -21,7 +21,9 @@
(save-message [this message]
"Save a new message. Returns the saved message with ID.")
(get-message [this id]
"Get a single message by ID"))
"Get a single message by ID")
(update-message [this id data]
"Update message fields. Returns updated message."))
(defn valid-session?
"Validate session data has required fields"
+120 -23
View File
@@ -16,6 +16,17 @@
(defn- now-iso []
(.toString (Instant/now)))
;; Status ID constants for foreign key references
(def status-ids
{:idle 1
:processing 2
:awaiting-permission 3})
(def status-names
{1 :idle
2 :processing
3 :awaiting-permission})
(defn- row->session
"Convert a database row to a session map"
[row]
@@ -43,14 +54,24 @@
(defn- session->row
"Convert a session map to database columns"
[{:keys [id provider external-id title working-dir status]}]
[{:keys [id provider external-id title working-dir spawn-dir status pending-permission auto-accept-edits]}]
(cond-> {}
id (assoc :id id)
provider (assoc :provider (name provider))
external-id (assoc :external_id external-id)
title (assoc :title title)
working-dir (assoc :working_dir working-dir)
status (assoc :status (name status))))
spawn-dir (assoc :spawn_dir spawn-dir)
status (assoc :status_id (get status-ids (keyword status) 1))
;; Handle pending-permission: can be a map (to serialize) or :clear (to set null)
(some? pending-permission)
(assoc :pending_permission
(if (= :clear pending-permission)
nil
(json/write-value-as-string pending-permission)))
;; Handle auto-accept-edits as integer (0 or 1)
(some? auto-accept-edits)
(assoc :auto_accept_edits (if auto-accept-edits 1 0))))
(defn- message->row
"Convert a message map to database columns"
@@ -70,14 +91,18 @@
["SELECT * FROM sessions ORDER BY updated_at DESC"]
{:builder-fn rs/as-unqualified-kebab-maps})]
(mapv (fn [row]
{:id (:id row)
:provider (keyword (:provider row))
:external-id (:external-id row)
:title (:title row)
:working-dir (:working-dir row)
:status (keyword (or (:status row) "idle"))
:created-at (:created-at row)
:updated-at (:updated-at row)})
(cond-> {:id (:id row)
:provider (keyword (:provider row))
:external-id (:external-id row)
:title (:title row)
:working-dir (:working-dir row)
:spawn-dir (:spawn-dir row)
:status (get status-names (:status-id row) :idle)
:auto-accept-edits (= 1 (:auto-accept-edits row))
:created-at (:created-at row)
:updated-at (:updated-at row)}
(:pending-permission row)
(assoc :pending-permission (json/read-value (:pending-permission row) mapper))))
rows)))
(get-session [_ id]
@@ -85,14 +110,18 @@
["SELECT * FROM sessions WHERE id = ?" id]
{:builder-fn rs/as-unqualified-kebab-maps})]
(when row
{:id (:id row)
:provider (keyword (:provider row))
:external-id (:external-id row)
:title (:title row)
:working-dir (:working-dir row)
:status (keyword (or (:status row) "idle"))
:created-at (:created-at row)
:updated-at (:updated-at row)})))
(cond-> {:id (:id row)
:provider (keyword (:provider row))
:external-id (:external-id row)
:title (:title row)
:working-dir (:working-dir row)
:spawn-dir (:spawn-dir row)
:status (get status-names (:status-id row) :idle)
:auto-accept-edits (= 1 (:auto-accept-edits row))
:created-at (:created-at row)
:updated-at (:updated-at row)}
(:pending-permission row)
(assoc :pending-permission (json/read-value (:pending-permission row) mapper))))))
(save-session [this session]
(let [id (or (:id session) (generate-id))
@@ -154,17 +183,28 @@
:content (:content row)
:metadata (when-let [m (:metadata row)]
(json/read-value m mapper))
:created-at (:created-at row)}))))
:created-at (:created-at row)})))
(update-message [this id data]
(let [row (message->row data)]
(sql/update! datasource :messages row {:id id})
(proto/get-message this id))))
(def schema
"SQLite schema for spiceflow"
["CREATE TABLE IF NOT EXISTS sessions (
["CREATE TABLE IF NOT EXISTS session_statuses (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
)"
"CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
external_id TEXT,
title TEXT,
working_dir TEXT,
status TEXT DEFAULT 'idle',
spawn_dir TEXT,
status_id INTEGER DEFAULT 1 REFERENCES session_statuses(id),
pending_permission TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)"
@@ -178,13 +218,70 @@
)"
"CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)"
"CREATE INDEX IF NOT EXISTS idx_sessions_provider ON sessions(provider)"
"CREATE INDEX IF NOT EXISTS idx_sessions_external_id ON sessions(external_id)"])
"CREATE INDEX IF NOT EXISTS idx_sessions_external_id ON sessions(external_id)"
"CREATE INDEX IF NOT EXISTS idx_sessions_status_id ON sessions(status_id)"])
(def migrations
"Database migrations for existing databases"
[;; Add pending_permission column if it doesn't exist
"ALTER TABLE sessions ADD COLUMN pending_permission TEXT"
;; Add status_id column if it doesn't exist (for migration from TEXT status)
"ALTER TABLE sessions ADD COLUMN status_id INTEGER DEFAULT 1 REFERENCES session_statuses(id)"
;; Add spawn_dir column - the original directory used when spawning Claude
;; This is separate from working_dir which tracks current directory
"ALTER TABLE sessions ADD COLUMN spawn_dir TEXT"
;; Add auto_accept_edits column - when enabled, pre-grants Write/Edit tool permissions
"ALTER TABLE sessions ADD COLUMN auto_accept_edits INTEGER DEFAULT 0"])
(defn- seed-statuses!
"Seed the session_statuses table with initial values"
[datasource]
(doseq [[status-name status-id] status-ids]
(try
(jdbc/execute! datasource
["INSERT OR IGNORE INTO session_statuses (id, name) VALUES (?, ?)"
status-id (name status-name)])
(catch Exception _
;; Ignore - already exists
nil))))
(defn- migrate-status-column!
"Migrate existing TEXT status values to status_id foreign key"
[datasource]
;; Update status_id based on existing status TEXT column
(try
(jdbc/execute! datasource
["UPDATE sessions SET status_id = CASE status
WHEN 'idle' THEN 1
WHEN 'running' THEN 2
WHEN 'processing' THEN 2
WHEN 'awaiting-permission' THEN 3
WHEN 'awaiting_permission' THEN 3
ELSE 1
END
WHERE status_id IS NULL OR status IS NOT NULL"])
(catch Exception _
;; Ignore - status column may not exist or already migrated
nil)))
(defn- run-migrations!
"Run migrations, ignoring errors for already-applied migrations"
[datasource]
(doseq [stmt migrations]
(try
(jdbc/execute! datasource [stmt])
(catch Exception _
;; Ignore - migration likely already applied
nil))))
(defn init-schema!
"Initialize database schema"
[datasource]
(doseq [stmt schema]
(jdbc/execute! datasource [stmt])))
(jdbc/execute! datasource [stmt]))
(seed-statuses! datasource)
(run-migrations! datasource)
(migrate-status-column! datasource))
(defn create-store
"Create a SQLite store with the given database path"
+155 -39
View File
@@ -10,9 +10,6 @@
;; Active process handles for running sessions
(defonce ^:private active-processes (ConcurrentHashMap.))
;; Pending permission requests: session-id -> {:tools [...] :denials [...]}
(defonce ^:private pending-permissions (ConcurrentHashMap.))
(defn get-adapter
"Get the appropriate adapter for a provider"
[provider]
@@ -35,22 +32,33 @@
(defn start-session
"Start a CLI process for a session"
[store session-id]
(log/debug "User action: start-session" {:session-id session-id})
(let [session (db/get-session store session-id)]
(when-not session
(throw (ex-info "Session not found" {:session-id session-id})))
(when (session-running? session-id)
(throw (ex-info "Session already running" {:session-id session-id})))
;; Use spawn-dir for spawning (this is the original directory the session was created in)
;; Fall back to working-dir for existing sessions that don't have spawn-dir yet
(let [adapter (get-adapter (:provider session))
spawn-dir (or (:spawn-dir session) (:working-dir session))
;; Pre-grant Write/Edit tools if auto-accept-edits is enabled
allowed-tools (when (:auto-accept-edits session)
["Write" "Edit"])
_ (log/debug "Starting session with spawn-dir:" spawn-dir "external-id:" (:external-id session) "allowed-tools:" allowed-tools)
handle (adapter/spawn-session adapter
(:external-id session)
{:working-dir (:working-dir session)})]
(cond-> {:working-dir spawn-dir}
(seq allowed-tools)
(assoc :allowed-tools allowed-tools)))]
(.put active-processes session-id handle)
(db/update-session store session-id {:status :running})
(db/update-session store session-id {:status :processing})
handle)))
(defn stop-session
"Stop a running CLI process for a session"
[store session-id]
(log/debug "User action: stop-session" {:session-id session-id})
(when-let [handle (.remove active-processes session-id)]
(let [session (db/get-session store session-id)
adapter (get-adapter (:provider session))]
@@ -60,6 +68,7 @@
(defn send-message-to-session
"Send a message to a running session"
[store session-id message]
(log/debug "User action: send-message" {:session-id session-id :message message})
(let [session (db/get-session store session-id)
_ (when-not session
(throw (ex-info "Session not found" {:session-id session-id})))
@@ -71,40 +80,88 @@
(db/save-message store {:session-id session-id
:role :user
:content message})
;; Send to CLI
(adapter/send-message adapter handle message)))
;; Send to CLI - for OpenCode, this returns an updated handle with the process
(let [result (adapter/send-message adapter handle message)]
(log/info "send-message result type:" (type result) "has-process:" (boolean (:process result)))
;; If result is a map with :process, it's an updated handle (OpenCode)
;; Store it so stream-session-response can use it
(when (and (map? result) (:process result))
(log/info "Storing updated handle with process for session:" session-id)
(.put active-processes session-id result))
result)))
;; Permission handling - defined before stream-session-response which uses them
;; Permission handling - persisted to database
(defn set-pending-permission
"Store a pending permission request for a session"
[session-id permission-request]
(.put pending-permissions session-id permission-request))
"Store a pending permission request for a session in the database"
[store session-id permission-request]
(db/update-session store session-id {:pending-permission permission-request}))
(defn get-pending-permission
"Get pending permission request for a session"
[session-id]
(.get pending-permissions session-id))
"Get pending permission request for a session from the database"
[store session-id]
(:pending-permission (db/get-session store session-id)))
(defn clear-pending-permission
"Clear pending permission for a session"
[session-id]
(.remove pending-permissions session-id))
"Clear pending permission for a session in the database"
[store session-id]
(db/update-session store session-id {:pending-permission :clear}))
(defn- extract-path-from-string
"Extract a Unix path from a string, looking for patterns like /home/user/dir"
[s]
(when (string? s)
;; Look for absolute paths - match /something/something pattern
;; Paths typically don't contain spaces, newlines, or common sentence punctuation
(let [path-pattern #"(/(?:home|root|usr|var|tmp|opt|etc|mnt|media|Users)[^\s\n\r\"'<>|:;,!?\[\]{}()]*)"
;; Also match generic absolute paths that look like directories
generic-pattern #"^(/[^\s\n\r\"'<>|:;,!?\[\]{}()]+)$"]
(or
;; First try to find a path that looks like a home/project directory
(when-let [match (re-find path-pattern s)]
(if (vector? match) (first match) match))
;; If the entire trimmed string is a single absolute path, use it
(when-let [match (re-find generic-pattern (clojure.string/trim s))]
(if (vector? match) (second match) match))))))
(defn- extract-working-dir-from-tool-result
"Extract working directory from tool result if it looks like a path.
Tool results from pwd, cd, or bash commands may contain directory paths."
[content]
(when (and (vector? content) (seq content))
;; Look through tool results for path-like content
(->> content
(filter #(= "tool_result" (:type %)))
(map :content)
(filter string?)
;; Check each line of the output for a path
(mapcat clojure.string/split-lines)
(map clojure.string/trim)
(keep extract-path-from-string)
;; Prefer longer paths (more specific), take the last one found
(sort-by count)
last)))
(defn stream-session-response
"Stream response from a running session, calling callback for each event"
[store session-id callback]
(log/info "stream-session-response starting for session:" session-id)
(let [session (db/get-session store session-id)
_ (when-not session
(throw (ex-info "Session not found" {:session-id session-id})))
handle (get-active-process session-id)
_ (log/info "Got handle for session:" session-id "has-process:" (boolean (:process handle)) "has-stdout:" (boolean (:stdout handle)))
_ (when-not handle
(throw (ex-info "Session not running" {:session-id session-id})))
adapter (get-adapter (:provider session))
content-buffer (StringBuilder.)]
content-buffer (StringBuilder.)
last-working-dir (atom nil)]
;; Read stream and accumulate content
(log/info "Starting to read stream for session:" session-id)
(adapter/read-stream adapter handle
(fn [event]
(log/info "Received event:" (:event event) "text:" (when (:text event) (subs (str (:text event)) 0 (min 50 (count (str (:text event)))))))
(log/debug "Agent response full event:" (pr-str event))
(callback event)
;; Accumulate text content
(when-let [text (:text event)]
@@ -115,12 +172,24 @@
(not (:external-id session)))
(log/debug "Capturing external session-id:" (:session-id event))
(db/update-session store session-id {:external-id (:session-id event)}))
;; Also capture working directory from cwd
(when (and (:cwd event)
(or (nil? (:working-dir session))
(empty? (:working-dir session))))
(log/debug "Capturing working directory:" (:cwd event))
(db/update-session store session-id {:working-dir (:cwd event)})))
;; Capture spawn-dir from init cwd (only for new sessions that don't have it)
;; This is the directory where Claude's project is, used for resuming
(when (and (:cwd event) (not (:spawn-dir session)))
(log/debug "Capturing spawn-dir from init:" (:cwd event))
(db/update-session store session-id {:spawn-dir (:cwd event)}))
;; Also set working directory from init cwd
(when (:cwd event)
(log/debug "Capturing working directory from init:" (:cwd event))
(reset! last-working-dir (:cwd event))))
;; Track working directory from tool results (e.g., after cd && pwd)
(when (and (= :user (:role event))
(vector? (:content event)))
(when-let [dir (extract-working-dir-from-tool-result (:content event))]
(log/info "Detected working directory from tool result:" dir)
(reset! last-working-dir dir)
;; Emit working-dir-update event so UI can update in real-time
(callback {:event :working-dir-update
:working-dir dir})))
;; Also capture external-id from result event if not already set
(when (and (= :result (:event event))
(:session-id event)
@@ -129,21 +198,52 @@
(db/update-session store session-id {:external-id (:session-id event)}))
;; On result event, check for permission requests
(when (= :result (:event event))
(let [content (.toString content-buffer)]
;; Save accumulated message if any
;; Use accumulated content, or fall back to result content
;; (resumed sessions may not stream content-delta events)
(let [accumulated (.toString content-buffer)
result-content (:content event)
content (if (seq accumulated)
accumulated
result-content)]
;; If no streaming content but result has content, emit it for the client
(when (and (empty? accumulated) (seq result-content))
(callback {:event :content-delta :text result-content}))
;; Save message if any content
(when (seq content)
(db/save-message store {:session-id session-id
:role :assistant
:content content}))
;; If there's a permission request, store it and emit event
;; If there's a permission request, save it as a message and emit event
(when-let [perm-req (:permission-request event)]
(log/info "Permission request detected:" perm-req)
(set-pending-permission session-id perm-req)
(callback {:event :permission-request
:permission-request perm-req}))))))
;; Build description for the permission message content
(let [description (->> (:denials perm-req)
(map (fn [{:keys [tool description]}]
(str tool ": " description)))
(clojure.string/join "\n"))
;; Save permission request as a system message
perm-msg (db/save-message store
{:session-id session-id
:role :system
:content description
:metadata {:type "permission-request"
:denials (:denials perm-req)
:tools (:tools perm-req)}})
msg-id (:id perm-msg)]
;; Store pending permission with message ID for later update
(set-pending-permission store session-id
(assoc perm-req :message-id msg-id))
(callback {:event :permission-request
:permission-request perm-req
:message-id msg-id
:message perm-msg})))))))
;; Update session with last known working directory
(when @last-working-dir
(log/info "Updating session working directory to:" @last-working-dir)
(db/update-session store session-id {:working-dir @last-working-dir}))
;; Update session status when stream ends
;; If there's a pending permission, set status to awaiting-permission
(let [new-status (if (get-pending-permission session-id)
(let [new-status (if (get-pending-permission store session-id)
:awaiting-permission
:idle)]
(db/update-session store session-id {:status new-status}))
@@ -163,18 +263,27 @@
response-type: :accept, :deny, or :steer
message: optional message for :deny or :steer responses"
[store session-id response-type message]
(log/debug "User action: permission-response" {:session-id session-id :response-type response-type :message message})
(let [session (db/get-session store session-id)
_ (when-not session
(throw (ex-info "Session not found" {:session-id session-id})))
pending (get-pending-permission session-id)
pending (get-pending-permission store session-id)
_ (when-not pending
(throw (ex-info "No pending permission request" {:session-id session-id})))
adapter (get-adapter (:provider session))
;; Build spawn options based on response type
opts (cond-> {:working-dir (:working-dir session)}
;; For :accept, grant the requested tools
(= response-type :accept)
(assoc :allowed-tools (:tools pending)))
;; Use spawn-dir for spawning, fall back to working-dir for existing sessions
spawn-dir (or (:spawn-dir session) (:working-dir session))
;; Auto-accept tools from session setting (always included if enabled)
auto-accept-tools (when (:auto-accept-edits session)
["Write" "Edit"])
;; Tools granted from accepting the permission request
granted-tools (when (= response-type :accept) (:tools pending))
;; Combine both sets of allowed tools
all-allowed-tools (seq (distinct (concat auto-accept-tools granted-tools)))
;; Build spawn options
opts (cond-> {:working-dir spawn-dir}
all-allowed-tools
(assoc :allowed-tools (vec all-allowed-tools)))
;; Determine the message to send
send-msg (case response-type
:accept "continue"
@@ -184,11 +293,18 @@
handle (adapter/spawn-session adapter
(:external-id session)
opts)]
;; Update the permission message with the resolution status
(when-let [msg-id (:message-id pending)]
(let [current-msg (db/get-message store msg-id)
current-metadata (or (:metadata current-msg) {})
status (name response-type)]
(db/update-message store msg-id
{:metadata (assoc current-metadata :status status)})))
;; Clear pending permission
(clear-pending-permission session-id)
(clear-pending-permission store session-id)
;; Store new process handle
(.put active-processes session-id handle)
(db/update-session store session-id {:status :running})
(db/update-session store session-id {:status :processing})
;; Send the response message
(adapter/send-message adapter handle send-msg)
handle))
+1
View File
@@ -0,0 +1 @@
Hello from OpenCode test
+1
View File
@@ -0,0 +1 @@
Hello from OpenCode test