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) - **State**: Svelte stores in `src/lib/stores/` (sessions, runtime selection)
- **API client**: `src/lib/api.ts` - HTTP and WebSocket clients - **API client**: `src/lib/api.ts` - HTTP and WebSocket clients
- **Components**: `src/lib/components/` - UI components - **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 - **PWA**: vite-plugin-pwa with Workbox service worker
- **Responsive**: Landscape mobile mode collapses header to hamburger menu
### Key Protocols ### Key Protocols
@@ -98,6 +104,29 @@ Server configuration via `server/resources/config.edn` or environment variables:
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory | | `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory |
| `OPENCODE_CMD` | opencode | OpenCode command | | `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 ## Session Flow
1. User opens PWA → sees list of tracked sessions 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 5. Server pipes user message to stdin
6. CLI streams response via stdout (JSONL format) 6. CLI streams response via stdout (JSONL format)
7. Server broadcasts to client via WebSocket 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 ## Tech Stack
+15
View File
@@ -7,6 +7,9 @@
"": { "": {
"name": "spiceflow-client", "name": "spiceflow-client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"marked": "^17.0.1"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.1", "@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.0", "@sveltejs/kit": "^2.5.0",
@@ -5005,6 +5008,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "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": "^5.0.12",
"vite-plugin-pwa": "^0.19.2", "vite-plugin-pwa": "^0.19.2",
"workbox-window": "^7.0.0" "workbox-window": "^7.0.0"
},
"dependencies": {
"marked": "^17.0.1"
} }
} }
+14
View File
@@ -61,4 +61,18 @@
.safe-top { .safe-top {
padding-top: env(safe-area-inset-top, 0); 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; title?: string;
'working-dir'?: string; 'working-dir'?: string;
workingDir?: string; workingDir?: string;
status: 'idle' | 'running' | 'completed'; status: 'idle' | 'processing' | 'awaiting-permission';
'auto-accept-edits'?: boolean;
autoAcceptEdits?: boolean;
'created-at'?: string; 'created-at'?: string;
createdAt?: string; createdAt?: string;
'updated-at'?: string; 'updated-at'?: string;
updatedAt?: string; updatedAt?: string;
messages?: Message[]; messages?: Message[];
'pending-permission'?: PermissionRequest;
pendingPermission?: PermissionRequest;
} }
export interface Message { export interface Message {
@@ -27,9 +31,26 @@ export interface Message {
createdAt?: string; 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 { export interface PermissionDenial {
tool: string; tool: string;
input: Record<string, unknown>; input: ToolInput;
description: string; description: string;
} }
@@ -47,6 +68,7 @@ export interface StreamEvent {
type?: string; type?: string;
message?: string; message?: string;
cwd?: string; cwd?: string;
'working-dir'?: string;
'permission-request'?: PermissionRequest; 'permission-request'?: PermissionRequest;
permissionRequest?: PermissionRequest; permissionRequest?: PermissionRequest;
} }
@@ -141,6 +163,10 @@ export class WebSocketClient {
private reconnectDelay = 1000; private reconnectDelay = 1000;
private listeners: Map<string, Set<(event: StreamEvent) => void>> = new Map(); private listeners: Map<string, Set<(event: StreamEvent) => void>> = new Map();
private globalListeners: Set<(event: StreamEvent) => void> = new Set(); 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`) { constructor(url: string = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/ws`) {
this.url = url; this.url = url;
@@ -158,11 +184,13 @@ export class WebSocketClient {
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('WebSocket connected'); console.log('WebSocket connected');
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.startHeartbeat();
resolve(); resolve();
}; };
this.ws.onclose = () => { this.ws.onclose = () => {
console.log('WebSocket disconnected'); console.log('WebSocket disconnected');
this.stopHeartbeat();
this.attemptReconnect(); this.attemptReconnect();
}; };
@@ -174,6 +202,7 @@ export class WebSocketClient {
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data) as StreamEvent; const data = JSON.parse(event.data) as StreamEvent;
console.log('[WS Raw] Message received:', data.event || data.type, data);
this.handleMessage(data); this.handleMessage(data);
} catch (e) { } catch (e) {
console.error('Failed to parse WebSocket message:', e); console.error('Failed to parse WebSocket message:', e);
@@ -183,6 +212,12 @@ export class WebSocketClient {
} }
private handleMessage(event: StreamEvent) { private handleMessage(event: StreamEvent) {
// Track pong responses for heartbeat
if (event.type === 'pong') {
this.lastPongTime = Date.now();
return;
}
// Notify global listeners // Notify global listeners
this.globalListeners.forEach((listener) => listener(event)); 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() { private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) { if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached'); console.error('Max reconnection attempts reached');
@@ -236,6 +297,7 @@ export class WebSocketClient {
} }
disconnect() { disconnect() {
this.stopHeartbeat();
this.ws?.close(); this.ws?.close();
this.ws = null; 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 message = '';
let textarea: HTMLTextAreaElement; 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() { function handleSubmit() {
const trimmed = message.trim(); const trimmed = message.trim();
@@ -15,6 +21,7 @@
dispatch('send', trimmed); dispatch('send', trimmed);
message = ''; message = '';
expanded = false;
resizeTextarea(); resizeTextarea();
} }
@@ -27,8 +34,16 @@
function resizeTextarea() { function resizeTextarea() {
if (!textarea) return; if (!textarea) return;
textarea.style.height = 'auto'; if (expanded) {
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px'; textarea.style.height = MIN_HEIGHT_EXPANDED + 'px';
} else {
textarea.style.height = MIN_HEIGHT_COLLAPSED + 'px';
}
}
function toggleExpanded() {
expanded = !expanded;
resizeTextarea();
} }
export function focus() { export function focus() {
@@ -38,6 +53,22 @@
<form on:submit|preventDefault={handleSubmit} class="border-t border-zinc-700 bg-zinc-900 safe-bottom"> <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"> <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 <textarea
bind:this={textarea} bind:this={textarea}
bind:value={message} bind:value={message}
@@ -45,8 +76,8 @@
on:input={resizeTextarea} on:input={resizeTextarea}
{placeholder} {placeholder}
{disabled} {disabled}
rows="1" rows={expanded ? 4 : 1}
class="input resize-none min-h-[44px] max-h-[150px] py-3" class="input resize-none py-3 {expanded ? 'min-h-[112px]' : 'min-h-[44px]'}"
></textarea> ></textarea>
<button <button
+277 -41
View File
@@ -1,13 +1,28 @@
<script lang="ts"> <script lang="ts">
import type { Message } from '$lib/api'; import type { Message, PermissionDenial, ToolInput } from '$lib/api';
import { onMount, afterUpdate } from 'svelte'; import { onMount, afterUpdate } from 'svelte';
import { marked } from 'marked';
import FileDiff from './FileDiff.svelte';
export let messages: Message[] = []; export let messages: Message[] = [];
export let streamingContent: string = ''; export let streamingContent: string = '';
export let isThinking: boolean = false; 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 container: HTMLDivElement;
let autoScroll = true; let autoScroll = true;
let collapsedMessages: Set<string> = new Set();
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
function scrollToBottom() { function scrollToBottom() {
if (autoScroll && container) { if (autoScroll && container) {
@@ -23,6 +38,34 @@
autoScroll = distanceFromBottom < threshold; 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(() => { onMount(() => {
scrollToBottom(); scrollToBottom();
}); });
@@ -37,11 +80,45 @@
system: 'bg-blue-500/20 border-blue-500/30 text-blue-200' system: 'bg-blue-500/20 border-blue-500/30 text-blue-200'
}; };
const roleLabels = { function isPermissionAccepted(message: Message): boolean {
user: 'You', return message.metadata?.type === 'permission-accepted';
assistant: 'Assistant', }
system: 'System'
}; 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> </script>
<div <div
@@ -72,29 +149,135 @@
</div> </div>
{:else} {:else}
{#each messages as message (message.id)} {#each messages as message (message.id)}
<div class="rounded-lg border p-3 {roleStyles[message.role]}"> {@const isCollapsed = collapsedMessages.has(message.id)}
<div class="flex items-center gap-2 mb-2"> {@const isCollapsible = shouldCollapse(message.content)}
<span {@const permStatus = getPermissionStatus(message)}
class="text-xs font-semibold uppercase tracking-wide {message.role === 'user' {#if isPermissionAccepted(message)}
? 'text-spice-400' <!-- Permission accepted message (legacy format) -->
: 'text-zinc-400'}" <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)}
> >
{roleLabels[message.role]} <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' : ''}"
>
{@html renderMarkdown(message.content)}
</div>
{#if isCollapsible}
<span
class="absolute right-3 top-3 text-zinc-500 transition-transform {isCollapsed ? '' : 'rotate-90'}"
>
</span> </span>
{/if}
</div> </div>
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed"> {/if}
{message.content}
</div>
</div>
{/each} {/each}
{#if isThinking && !streamingContent} {#if isThinking && !streamingContent}
<div class="rounded-lg border p-3 {roleStyles.assistant}"> <div class="rounded-lg border p-3 {roleStyles.assistant}">
<div class="flex items-center gap-2"> <div class="flex items-center gap-1">
<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"></span>
<span <span
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce" class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
@@ -104,31 +287,84 @@
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce" class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
style="animation-delay: 0.2s" style="animation-delay: 0.2s"
></span> ></span>
</span>
</div> </div>
</div> </div>
{:else if streamingContent} {:else if streamingContent}
<div class="rounded-lg border p-3 {roleStyles.assistant}"> <div class="rounded-lg border p-3 {roleStyles.assistant}">
<div class="flex items-center gap-2 mb-2"> <div class="text-sm break-words font-mono leading-relaxed markdown-content">
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400"> {@html renderMarkdown(streamingContent)}<span class="animate-pulse">|</span>
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> </div>
</div> </div>
{/if} {/if}
{/if} {/if}
</div> </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"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; 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 permission: PermissionRequest;
export let assistantName: string = 'Assistant';
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
accept: void; accept: void;
@@ -10,6 +12,29 @@
steer: void; 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() { function handleAccept() {
dispatch('accept'); dispatch('accept');
} }
@@ -42,13 +67,45 @@
</div> </div>
<div class="flex-1 min-w-0"> <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"> <ul class="mt-2 space-y-2">
{#each permission.denials as denial} {#each permission.denials as denial, index}
<li class="text-sm text-zinc-300 font-mono truncate"> <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="text-amber-400">{denial.tool}:</span>
{denial.description} <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> </li>
{/each} {/each}
</ul> </ul>
@@ -56,19 +113,19 @@
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
<button <button
on:click={handleAccept} 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 Accept
</button> </button>
<button <button
on:click={handleDeny} 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 Deny
</button> </button>
<button <button
on:click={handleSteer} 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... No, and...
</button> </button>
+6 -7
View File
@@ -16,7 +16,6 @@
$: workingDir = session['working-dir'] || session.workingDir || ''; $: workingDir = session['working-dir'] || session.workingDir || '';
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || ''; $: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
$: shortId = externalId.slice(0, 8); $: shortId = externalId.slice(0, 8);
$: projectName = workingDir.split('/').pop() || workingDir;
function formatTime(iso: string): string { function formatTime(iso: string): string {
if (!iso) return ''; if (!iso) return '';
@@ -33,10 +32,10 @@
return 'Just now'; return 'Just now';
} }
const statusColors = { const statusColors: Record<string, string> = {
idle: 'bg-zinc-600', idle: 'bg-zinc-600',
running: 'bg-green-500 animate-pulse', processing: 'bg-green-500 animate-pulse',
completed: 'bg-blue-500' 'awaiting-permission': 'bg-amber-500 animate-pulse'
}; };
const providerColors = { const providerColors = {
@@ -62,9 +61,9 @@
{session.title || `Session ${shortId}`} {session.title || `Session ${shortId}`}
</h3> </h3>
{#if projectName} {#if workingDir}
<p class="text-sm text-zinc-400 truncate mt-1"> <p class="text-sm text-zinc-400 mt-1 break-all">
{projectName} {workingDir}
</p> </p>
{/if} {/if}
</div> </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 { try {
const session = await api.getSession(id); 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) => ({ update((s) => ({
...s, ...s,
session, session,
messages: session.messages || [], messages: session.messages || [],
pendingPermission,
loading: false loading: false
})); }));
@@ -174,8 +177,34 @@ function createActiveSessionStore() {
const state = get(); const state = get();
if (!state.session || !state.pendingPermission) return; if (!state.session || !state.pendingPermission) return;
// Clear pending permission immediately const permission = state.pendingPermission as PermissionRequest & { 'message-id'?: string };
update((s) => ({ ...s, pendingPermission: null })); 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 { try {
await api.respondToPermission(state.session.id, response, message); await api.respondToPermission(state.session.id, response, message);
@@ -201,6 +230,26 @@ function createActiveSessionStore() {
throw e; 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() { clear() {
if (unsubscribeWs) { if (unsubscribeWs) {
unsubscribeWs(); unsubscribeWs();
@@ -225,6 +274,7 @@ function createActiveSessionStore() {
} }
function handleStreamEvent(event: StreamEvent) { function handleStreamEvent(event: StreamEvent) {
console.log('[WS] Received event:', event.event, event);
if (event.event === 'init' && event.cwd) { if (event.event === 'init' && event.cwd) {
// Update session's working directory from init event // Update session's working directory from init event
update((s) => { update((s) => {
@@ -234,6 +284,26 @@ function createActiveSessionStore() {
session: { ...s.session, 'working-dir': event.cwd, workingDir: event.cwd } 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) { } else if (event.event === 'content-delta' && event.text) {
update((s) => ({ ...s, streamingContent: s.streamingContent + event.text, isThinking: false })); update((s) => ({ ...s, streamingContent: s.streamingContent + event.text, isThinking: false }));
} else if (event.event === 'message-stop') { } else if (event.event === 'message-stop') {
@@ -257,8 +327,25 @@ function createActiveSessionStore() {
}); });
} else if (event.event === 'permission-request') { } else if (event.event === 'permission-request') {
const permReq = event['permission-request'] || event.permissionRequest; 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) { 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') { } else if (event.event === 'error') {
update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false })); 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) => export const processingSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
$sessions.sessions.filter((s) => s.status === 'running') $sessions.sessions.filter((s) => s.status === 'processing')
); );
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; 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 type { Session } from '$lib/api';
import SessionCard from '$lib/components/SessionCard.svelte'; import SessionCard from '$lib/components/SessionCard.svelte';
@@ -111,10 +111,10 @@
</div> </div>
</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"> <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"> <span class="text-xs text-green-400">
{$runningSessions.length} session{$runningSessions.length === 1 ? '' : 's'} running {$processingSessions.length} session{$processingSessions.length === 1 ? '' : 's'} processing
</span> </span>
</div> </div>
{/if} {/if}
+93 -9
View File
@@ -6,14 +6,17 @@
import MessageList from '$lib/components/MessageList.svelte'; import MessageList from '$lib/components/MessageList.svelte';
import InputBar from '$lib/components/InputBar.svelte'; import InputBar from '$lib/components/InputBar.svelte';
import PermissionRequest from '$lib/components/PermissionRequest.svelte'; import PermissionRequest from '$lib/components/PermissionRequest.svelte';
import SessionSettings from '$lib/components/SessionSettings.svelte';
$: sessionId = $page.params.id; $: sessionId = $page.params.id;
let inputBar: InputBar; let inputBar: InputBar;
let messageList: MessageList;
let steerMode = false; let steerMode = false;
let isEditingTitle = false; let isEditingTitle = false;
let editedTitle = ''; let editedTitle = '';
let titleInput: HTMLInputElement; let titleInput: HTMLInputElement;
let menuOpen = false;
onMount(() => { onMount(() => {
if (sessionId) { if (sessionId) {
@@ -90,11 +93,17 @@
$: shortId = externalId ? externalId.slice(0, 8) : session?.id?.slice(0, 8) || ''; $: shortId = externalId ? externalId.slice(0, 8) : session?.id?.slice(0, 8) || '';
$: projectName = workingDir.split('/').pop() || ''; $: projectName = workingDir.split('/').pop() || '';
$: isNewSession = !externalId && $activeSession.messages.length === 0; $: 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> = { const statusColors: Record<string, string> = {
idle: 'bg-zinc-600', idle: 'bg-zinc-600',
running: 'bg-green-500 animate-pulse', processing: 'bg-green-500 animate-pulse',
completed: 'bg-blue-500' 'awaiting-permission': 'bg-amber-500 animate-pulse'
}; };
</script> </script>
@@ -102,8 +111,10 @@
<title>{session?.title || `Session ${shortId}`} - Spiceflow</title> <title>{session?.title || `Session ${shortId}`} - Spiceflow</title>
</svelte:head> </svelte:head>
<!-- Header --> <svelte:window on:click={() => (menuOpen = false)} />
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
<!-- 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"> <div class="flex items-center gap-3">
<button <button
on:click={goBack} on:click={goBack}
@@ -148,6 +159,12 @@
{/if} {/if}
</div> </div>
<SessionSettings
{autoAcceptEdits}
provider={session.provider}
on:toggleAutoAccept={handleToggleAutoAccept}
/>
<span <span
class="text-xs font-medium uppercase {session.provider === 'claude' class="text-xs font-medium uppercase {session.provider === 'claude'
? 'text-spice-400' ? 'text-spice-400'
@@ -159,6 +176,65 @@
</div> </div>
</header> </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 --> <!-- Content -->
{#if $activeSession.error} {#if $activeSession.error}
<div class="flex-1 flex items-center justify-center p-4"> <div class="flex-1 flex items-center justify-center p-4">
@@ -181,18 +257,26 @@
</div> </div>
{:else} {:else}
{#if workingDir} {#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"> <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" /> <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> </svg>
<span class="truncate font-mono">{workingDir}</span> <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> </div>
{/if} {/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} {#if $activeSession.pendingPermission}
<PermissionRequest <PermissionRequest
permission={$activeSession.pendingPermission} permission={$activeSession.pendingPermission}
{assistantName}
on:accept={handlePermissionAccept} on:accept={handlePermissionAccept}
on:deny={handlePermissionDeny} on:deny={handlePermissionDeny}
on:steer={handlePermissionSteer} on:steer={handlePermissionSteer}
@@ -202,10 +286,10 @@
<InputBar <InputBar
bind:this={inputBar} bind:this={inputBar}
on:send={handleSend} on:send={handleSend}
disabled={session?.status === 'running' && $activeSession.streamingContent !== ''} disabled={session?.status === 'processing' && $activeSession.streamingContent !== ''}
placeholder={steerMode placeholder={steerMode
? 'Tell Claude what to do instead...' ? `Tell ${assistantName} what to do instead...`
: session?.status === 'running' : session?.status === 'processing'
? 'Waiting for response...' ? 'Waiting for response...'
: 'Type a message...'} : 'Type a message...'}
/> />
+3
View File
@@ -20,6 +20,9 @@ export default {
}, },
fontFamily: { fontFamily: {
mono: ['JetBrains Mono', 'Fira Code', 'monospace'] 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', host: '0.0.0.0',
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3000', target: `http://localhost:${process.env.VITE_BACKEND_PORT || 3000}`,
changeOrigin: true, changeOrigin: true,
ws: true ws: true
} }
+7 -4
View File
@@ -23,10 +23,12 @@ npm run test:ui
The e2e setup automatically manages both servers: 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 2. **Global Teardown** (`global-teardown.ts`) - Stops both servers
3. **Server Utils** (`server-utils.ts`) - Spawns Clojure and Vite processes, waits for health checks 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. Tests use Playwright's `page` fixture for browser interactions and `request` fixture for direct API calls.
## Test Database ## Test Database
@@ -37,12 +39,13 @@ E2E tests use a separate database (`server/test-e2e.db`) to avoid polluting the
```typescript ```typescript
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { E2E_BACKEND_URL } from '../playwright.config';
test('example', async ({ page, request }) => { test('example', async ({ page, request }) => {
// Direct API call // Direct API call - use E2E_BACKEND_URL for backend requests
const response = await request.get('http://localhost:3000/api/sessions'); 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 page.goto('/');
await expect(page.locator('h1')).toBeVisible(); await expect(page.locator('h1')).toBeVisible();
}); });
+19 -1
View File
@@ -1,12 +1,30 @@
import { startServers } from './server-utils.js'; 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() { export default async function globalSetup() {
// Clean up test files from previous runs
cleanupTestFiles();
// Skip if servers are managed externally (e.g., by scripts/test) // Skip if servers are managed externally (e.g., by scripts/test)
if (process.env.SKIP_SERVER_SETUP) { if (process.env.SKIP_SERVER_SETUP) {
console.log('\n=== Skipping server setup (SKIP_SERVER_SETUP is set) ===\n'); console.log('\n=== Skipping server setup (SKIP_SERVER_SETUP is set) ===\n');
return; return;
} }
console.log('\n=== Starting E2E Test Environment ===\n'); 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'); console.log('\n=== E2E Test Environment Ready ===\n');
} }
+16
View File
@@ -1,6 +1,22 @@
import { stopServers } from './server-utils.js'; 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() { export default async function globalTeardown() {
// Clean up test files
cleanupTestFiles();
// Skip if servers are managed externally (e.g., by scripts/test) // Skip if servers are managed externally (e.g., by scripts/test)
if (process.env.SKIP_SERVER_SETUP) { if (process.env.SKIP_SERVER_SETUP) {
console.log('\n=== Skipping server teardown (SKIP_SERVER_SETUP is set) ===\n'); 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'; 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({ export default defineConfig({
testDir: './tests', testDir: './tests',
fullyParallel: false, fullyParallel: false,
@@ -9,7 +15,7 @@ export default defineConfig({
reporter: 'list', reporter: 'list',
timeout: 30000, timeout: 30000,
use: { use: {
baseURL: 'https://localhost:5173', baseURL: E2E_FRONTEND_URL,
trace: 'on-first-retry', trace: 'on-first-retry',
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
}, },
+1
View File
@@ -109,6 +109,7 @@ export async function startFrontend(port = 5173, backendPort = 3000): Promise<Ch
cwd: CLIENT_DIR, cwd: CLIENT_DIR,
env: { env: {
...process.env, ...process.env,
VITE_BACKEND_PORT: String(backendPort),
}, },
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}); });
+3 -2
View File
@@ -1,8 +1,9 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { E2E_BACKEND_URL } from '../playwright.config';
test.describe('Basic E2E Tests', () => { test.describe('Basic E2E Tests', () => {
test('backend health check', async ({ request }) => { 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(); expect(response.ok()).toBeTruthy();
const body = await response.json(); const body = await response.json();
expect(body.status).toBe('ok'); expect(body.status).toBe('ok');
@@ -15,7 +16,7 @@ test.describe('Basic E2E Tests', () => {
}); });
test('sessions list loads empty', async ({ request }) => { 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(); expect(response.ok()).toBeTruthy();
const sessions = await response.json(); const sessions = await response.json();
expect(Array.isArray(sessions)).toBeTruthy(); 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'; 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 }) => { test('permission approval allows file creation and reading', async ({ page }) => {
// Increase timeout for this test since it involves real Claude interaction // Increase timeout for this test since it involves real Claude interaction
test.setTimeout(180000); test.setTimeout(180000);
@@ -21,20 +21,25 @@ test.describe('Permissions Workflow', () => {
await page.goto('/'); await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i); 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"]'); const createButton = page.locator('button[title="New Session"]');
await expect(createButton).toBeVisible(); await expect(createButton).toBeVisible();
await createButton.click(); 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\/.+/); await page.waitForURL(/\/session\/.+/);
console.log('[Test] Navigated to session page:', page.url()); 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=Loading')).not.toBeVisible({ timeout: 5000 });
await expect(page.locator('text=No messages yet')).toBeVisible(); 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'); const textarea = page.locator('textarea');
await expect(textarea).toBeVisible(); await expect(textarea).toBeVisible();
await textarea.fill( await textarea.fill(
@@ -45,73 +50,75 @@ test.describe('Permissions Workflow', () => {
await expect(sendButton).toBeEnabled(); await expect(sendButton).toBeEnabled();
await sendButton.click(); await sendButton.click();
// 6. Verify user message appears // 7. Verify user message appears
await expect( await expect(
page.locator('text=Create a file called foo.md') page.locator('text=Create a file called foo.md')
).toBeVisible(); ).toBeVisible();
console.log('[Test] User message displayed'); console.log('[Test] User message displayed');
// 6b. Verify thinking indicator appears immediately // 7b. Verify thinking indicator appears immediately
const assistantBubble = page.locator('.rounded-lg.border').filter({ // The thinking indicator shows bouncing dots
has: page.locator('text=Assistant') const bouncingDotsIndicator = page.locator('.animate-bounce');
}).first(); await expect(bouncingDotsIndicator.first()).toBeVisible({ timeout: 2000 });
await expect(assistantBubble).toBeVisible({ timeout: 2000 });
console.log('[Test] Thinking indicator appeared immediately'); 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'); const permissionUI = page.locator('text=Claude needs permission');
await expect(permissionUI).toBeVisible({ timeout: 60000 }); await expect(permissionUI).toBeVisible({ timeout: 60000 });
console.log('[Test] Permission request UI appeared'); console.log('[Test] Permission request UI appeared');
// 8. Verify the permission shows Write tool for foo.md // 9. Verify the permission shows Write tool for foo.md
const permissionDescription = page.locator('li.font-mono').filter({ // 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 hasText: /Write.*foo\.md|create.*foo\.md/i
}).first(); }).first();
await expect(permissionDescription).toBeVisible(); await expect(permissionDescription).toBeVisible();
console.log('[Test] Permission shows foo.md file creation'); 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")'); const acceptButton = page.locator('button:has-text("Accept")');
await expect(acceptButton).toBeVisible(); await expect(acceptButton).toBeVisible();
await acceptButton.click(); await acceptButton.click();
console.log('[Test] Clicked Accept button'); 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 }); await expect(permissionUI).not.toBeVisible({ timeout: 10000 });
console.log('[Test] Permission UI disappeared'); 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); await page.waitForTimeout(2000);
const bouncingDots = page.locator('.animate-bounce'); 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(bouncingDots).toHaveCount(0, { timeout: 60000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 }); await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
console.log('[Test] First streaming complete'); 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({ const assistantMessages = page.locator('.rounded-lg.border').filter({
has: page.locator('text=Assistant') has: page.locator('.markdown-content')
}); });
const messageCountBefore = await assistantMessages.count(); 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 textarea.fill('Read the contents of foo.md and tell me what it says. Quote the file contents.');
await sendButton.click(); await sendButton.click();
console.log('[Test] Asked Claude to read the file'); 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 }); 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 // Wait for streaming to complete on the new message
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 }); await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 }); await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
console.log('[Test] Second streaming complete'); 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 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); console.log('[Test] Claude read back:', responseText);
// The response should contain "My Haiku" which we asked Claude to title the file // 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'; import { test, expect } from '@playwright/test';
test.describe('Chat Workflow', () => { test.describe('OpenCode Chat Workflow', () => {
test('create new chat and send message to Claude', async ({ page }) => { // 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 // Enable console logging to debug WebSocket issues
page.on('console', (msg) => { page.on('console', (msg) => {
console.log(`[Browser ${msg.type()}]`, msg.text()); console.log(`[Browser ${msg.type()}]`, msg.text());
@@ -31,36 +33,41 @@ test.describe('Chat Workflow', () => {
await page.goto('/'); await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i); 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"]'); const createButton = page.locator('button[title="New Session"]');
await expect(createButton).toBeVisible(); await expect(createButton).toBeVisible();
await createButton.click(); 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\/.+/); await page.waitForURL(/\/session\/.+/);
console.log('[Test] Navigated to session page:', page.url()); 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 }); 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(); 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'); const textarea = page.locator('textarea');
await expect(textarea).toBeVisible(); await expect(textarea).toBeVisible();
await textarea.fill('say hi. respond in a single concise sentence'); 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"]'); const sendButton = page.locator('button[type="submit"]');
await expect(sendButton).toBeEnabled(); await expect(sendButton).toBeEnabled();
await sendButton.click(); 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(); await expect(page.locator('text=say hi. respond in a single concise sentence')).toBeVisible();
console.log('[Test] User message displayed'); 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) // The assistant bubble with bouncing dots should show right away (isThinking state)
const assistantMessage = page.locator('.rounded-lg.border').filter({ const assistantMessage = page.locator('.rounded-lg.border').filter({
has: page.locator('text=Assistant') has: page.locator('text=Assistant')
@@ -75,25 +82,26 @@ test.describe('Chat Workflow', () => {
await expect(bouncingDotsInAssistant.first()).toBeVisible({ timeout: 2000 }); await expect(bouncingDotsInAssistant.first()).toBeVisible({ timeout: 2000 });
console.log('[Test] Bouncing dots visible in thinking state'); 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 // The streaming indicator has animate-bounce dots and animate-pulse cursor
// Note: With fast responses, the indicator may appear and disappear quickly, // Note: With fast responses, the indicator may appear and disappear quickly,
// so we just verify it's gone after the response is visible // so we just verify it's gone after the response is visible
const bouncingDots = page.locator('.animate-bounce'); 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) // Wait for streaming indicators to disappear (they should be gone after message-stop)
await expect(bouncingDots).toHaveCount(0, { timeout: 30000 }); await expect(bouncingDots).toHaveCount(0, { timeout: 30000 });
await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 }); await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 });
console.log('[Test] Streaming complete - progress indicator disappeared'); 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(); const responseText = await assistantMessage.locator('.font-mono').textContent();
console.log('[Test] Assistant response text:', responseText); console.log('[Test] Assistant response text:', responseText);
expect(responseText).toBeTruthy(); expect(responseText).toBeTruthy();
expect(responseText!.length).toBeGreaterThan(0); 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 // The working directory should be captured from the init event and displayed
const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first(); const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first();
await expect(workingDirIndicator).toBeVisible({ timeout: 5000 }); 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
+45 -8
View File
@@ -1,7 +1,5 @@
#!/bin/bash #!/bin/bash
# Development script - runs backend and frontend concurrently # Development script - runs backend REPL with auto-reload and frontend
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")" ROOT_DIR="$(dirname "$SCRIPT_DIR")"
@@ -13,21 +11,56 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
BACKEND_PID=""
FRONTEND_PID=""
NREPL_PORT=7888
cleanup() { cleanup() {
echo -e "\n${YELLOW}Shutting down...${NC}" echo -e "\n${YELLOW}Shutting down...${NC}"
# 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 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 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 exit 0
} }
trap cleanup SIGINT SIGTERM trap cleanup SIGINT SIGTERM SIGHUP EXIT
echo -e "${BLUE}=== Starting Spiceflow Development Environment ===${NC}\n" echo -e "${BLUE}=== Starting Spiceflow Development Environment ===${NC}\n"
# Start backend # Start backend REPL with auto-reload
echo -e "${GREEN}Starting backend server...${NC}" echo -e "${GREEN}Starting backend REPL with auto-reload...${NC}"
cd "$ROOT_DIR/server" 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=$! BACKEND_PID=$!
# Wait for backend to be ready # 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 sleep 1
done done
echo -e "${GREEN}Backend ready on http://localhost:3000${NC}" 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 # Start frontend
echo -e "${GREEN}Starting frontend server...${NC}" 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 "\n${BLUE}=== Spiceflow Development Environment Ready ===${NC}"
echo -e "${GREEN}Backend:${NC} http://localhost:3000" 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}Frontend:${NC} https://localhost:5173"
echo -e "${GREEN}Phone:${NC} https://${LOCAL_IP}: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 for processes
wait wait
+10 -6
View File
@@ -39,31 +39,35 @@ echo -e "${BLUE}=== Starting Spiceflow Test Environment ===${NC}\n"
# Clean up old test database # Clean up old test database
rm -f "$ROOT_DIR/server/test-e2e.db" 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 # Start backend with test database
echo -e "${GREEN}Starting backend server with test database...${NC}" echo -e "${GREEN}Starting backend server with test database...${NC}"
cd "$ROOT_DIR/server" 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=$! BACKEND_PID=$!
# Wait for backend to be ready # Wait for backend to be ready
echo -e "${YELLOW}Waiting for backend...${NC}" 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 sleep 1
done 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 # Start frontend
echo -e "${GREEN}Starting frontend server...${NC}" echo -e "${GREEN}Starting frontend server...${NC}"
cd "$ROOT_DIR/client" 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=$! FRONTEND_PID=$!
# Wait for frontend to be ready # Wait for frontend to be ready
echo -e "${YELLOW}Waiting for frontend...${NC}" 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 sleep 1
done 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 # Run e2e tests
echo -e "${BLUE}=== Running E2E Tests ===${NC}\n" echo -e "${BLUE}=== Running E2E Tests ===${NC}\n"
+6 -1
View File
@@ -32,4 +32,9 @@
:main-opts ["-m" "kaocha.runner"]} :main-opts ["-m" "kaocha.runner"]}
:repl {:main-opts ["-m" "nrepl.cmdline" "-i"] :repl {:main-opts ["-m" "nrepl.cmdline" "-i"]
:extra-deps {nrepl/nrepl {:mvn/version "1.1.0"} :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"?> <?xml version="1.0" encoding="UTF-8"?>
<configuration> <configuration>
<!-- Console appender - INFO and above only -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<encoder> <encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder> </encoder>
</appender> </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"/> <logger name="org.eclipse.jetty" level="WARN"/>
<root level="INFO"> <root level="DEBUG">
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT"/>
<appender-ref ref="FILE"/>
</root> </root>
</configuration> </configuration>
+138 -38
View File
@@ -1,5 +1,8 @@
(ns spiceflow.adapters.opencode (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] (:require [spiceflow.adapters.protocol :as proto]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.java.shell :as shell] [clojure.java.shell :as shell]
@@ -60,26 +63,64 @@
(log/warn "Failed to parse session export:" (.getMessage e)) (log/warn "Failed to parse session export:" (.getMessage e))
nil))) nil)))
(defn- parse-stream-output (defn- parse-json-event
"Parse a line of streaming output from OpenCode" "Parse a JSON event from OpenCode's --format json output"
[line] [line]
(try (try
(when (and line (not (str/blank? line))) (when (and line (not (str/blank? line)))
(if (str/starts-with? line "{")
(let [data (json/read-value line mapper)] (let [data (json/read-value line mapper)]
(cond (log/debug "OpenCode JSON event:" (:type data))
(:content data) {:event :content-delta (case (:type data)
:text (:content data)} ;; Step start - beginning of a new step
(:done data) {:event :message-stop} "step_start" {:event :init
(:error data) {:event :error :session-id (:sessionID data)
:message (:error data)} :cwd (get-in data [:part :cwd])}
:else {:raw data}))
;; Plain text output ;; Text content - the actual response text
{:event :content-delta "text" {:event :content-delta
:text line})) :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 (catch Exception e
(log/debug "Failed to parse line:" line) (log/debug "Failed to parse JSON line:" line (.getMessage e))
{:event :content-delta :text line}))) ;; 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] (defrecord OpenCodeAdapter [command]
proto/AgentAdapter proto/AgentAdapter
@@ -92,44 +133,103 @@
[])) []))
(spawn-session [_ session-id opts] (spawn-session [_ session-id opts]
(let [{:keys [working-dir]} opts ;; For OpenCode, we don't start a process here - we just create a handle
args [command "run" "--session" session-id] ;; that stores the configuration. The actual process is started when send-message is called.
pb (ProcessBuilder. args)] (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 [_ handle message]
(try
(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 (when working-dir
(.directory pb (io/file working-dir))) (.directory pb (io/file working-dir)))
;; Don't merge stderr into stdout - keep them separate
(.redirectErrorStream pb false) (.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))})))
(send-message [_ {:keys [stdin]} message] ;; Start the process
(try (let [process (.start pb)
(.write stdin message) stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
(.newLine stdin) stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))]
(.flush stdin) ;; Update the handle with the running process
true ;; 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 (catch Exception e
(log/error "Failed to send message:" (.getMessage e)) (log/error "Failed to start OpenCode process:" (.getMessage e))
false))) 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 (try
(loop [] (loop []
(when-let [line (.readLine stdout)] (when-let [line (.readLine stderr)]
(when-let [parsed (proto/parse-output this line)] (log/info "[OpenCode stderr]" line)
(callback parsed))
(recur))) (recur)))
(catch Exception e (catch Exception e
(log/debug "Stream ended:" (.getMessage 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)]
(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/error "Stream error:" (.getMessage e) (class e)))))
(kill-process [_ {:keys [process]}] (kill-process [_ {:keys [process]}]
(when process (when process
(.destroyForcibly process))) (.destroyForcibly process)))
(parse-output [_ line] (parse-output [_ line]
(parse-stream-output line))) (parse-json-event line)))
(defn create-adapter (defn create-adapter
"Create an OpenCode adapter" "Create an OpenCode adapter"
+6 -1
View File
@@ -41,6 +41,7 @@
[store] [store]
(fn [request] (fn [request]
(let [body (:body request)] (let [body (:body request)]
(log/debug "API request: create-session" {:body body})
(if (db/valid-session? body) (if (db/valid-session? body)
(let [session (db/save-session store body)] (let [session (db/save-session store body)]
(-> (json-response session) (-> (json-response session)
@@ -51,6 +52,7 @@
[store] [store]
(fn [request] (fn [request]
(let [id (get-in request [:path-params :id])] (let [id (get-in request [:path-params :id])]
(log/debug "API request: delete-session" {:session-id id})
(if (db/get-session store id) (if (db/get-session store id)
(do (do
(manager/stop-session store id) (manager/stop-session store id)
@@ -63,8 +65,9 @@
(fn [request] (fn [request]
(let [id (get-in request [:path-params :id]) (let [id (get-in request [:path-params :id])
body (:body request)] body (:body request)]
(log/debug "API request: update-session" {:session-id id :body body})
(if (db/get-session store id) (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)) (json-response updated))
(error-response 404 "Session not found"))))) (error-response 404 "Session not found")))))
@@ -73,6 +76,7 @@
(fn [request] (fn [request]
(let [id (get-in request [:path-params :id]) (let [id (get-in request [:path-params :id])
message (get-in request [:body :message])] 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)] (if-let [session (db/get-session store id)]
(try (try
;; Send message and start streaming in a separate thread ;; Send message and start streaming in a separate thread
@@ -101,6 +105,7 @@
(let [id (get-in request [:path-params :id]) (let [id (get-in request [:path-params :id])
{:keys [response message]} (:body request) {:keys [response message]} (:body request)
response-type (keyword response)] 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-let [session (db/get-session store id)]
(if (#{:accept :deny :steer} response-type) (if (#{:accept :deny :steer} response-type)
(try (try
+18 -2
View File
@@ -8,6 +8,14 @@
(def ^:private mapper (json/object-mapper {:encode-key-fn name (def ^:private mapper (json/object-mapper {:encode-key-fn name
:decode-key-fn keyword})) :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} ;; Connected WebSocket sessions: session-id -> #{sockets}
(defonce ^:private connections (ConcurrentHashMap.)) (defonce ^:private connections (ConcurrentHashMap.))
@@ -27,7 +35,8 @@
(defn broadcast-to-session (defn broadcast-to-session
"Broadcast an event to all WebSocket connections subscribed to a session" "Broadcast an event to all WebSocket connections subscribed to a session"
[session-id event] [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)] (when-let [subscribers (.get connections session-id)]
(let [message (assoc event :session-id session-id)] (let [message (assoc event :session-id session-id)]
(doseq [socket subscribers] (doseq [socket subscribers]
@@ -47,7 +56,14 @@
(let [new-set (ConcurrentHashMap/newKeySet)] (let [new-set (ConcurrentHashMap/newKeySet)]
(.putIfAbsent connections session-id new-set) (.putIfAbsent connections session-id new-set)
(or (.get 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 (defn- unsubscribe-from-session
"Unsubscribe a WebSocket socket from a session" "Unsubscribe a WebSocket socket from a session"
+2
View File
@@ -25,6 +25,8 @@
(defstate server (defstate server
:start (let [port (get-in config/config [:server :port] 3000) :start (let [port (get-in config/config [:server :port] 3000)
host (get-in config/config [:server :host] "0.0.0.0") 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) api-app (routes/create-app store ws/broadcast-to-session)
;; Wrap the app to handle WebSocket upgrades on /api/ws ;; Wrap the app to handle WebSocket upgrades on /api/ws
app (fn [request] app (fn [request]
+4
View File
@@ -65,6 +65,10 @@
new-message)) new-message))
(get-message [_ id] (get-message [_ id]
(get @messages id))
(update-message [_ id data]
(swap! messages update id merge data)
(get @messages id))) (get @messages id)))
(defn create-store (defn create-store
+3 -1
View File
@@ -21,7 +21,9 @@
(save-message [this message] (save-message [this message]
"Save a new message. Returns the saved message with ID.") "Save a new message. Returns the saved message with ID.")
(get-message [this 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? (defn valid-session?
"Validate session data has required fields" "Validate session data has required fields"
+110 -13
View File
@@ -16,6 +16,17 @@
(defn- now-iso [] (defn- now-iso []
(.toString (Instant/now))) (.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 (defn- row->session
"Convert a database row to a session map" "Convert a database row to a session map"
[row] [row]
@@ -43,14 +54,24 @@
(defn- session->row (defn- session->row
"Convert a session map to database columns" "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-> {} (cond-> {}
id (assoc :id id) id (assoc :id id)
provider (assoc :provider (name provider)) provider (assoc :provider (name provider))
external-id (assoc :external_id external-id) external-id (assoc :external_id external-id)
title (assoc :title title) title (assoc :title title)
working-dir (assoc :working_dir working-dir) 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 (defn- message->row
"Convert a message map to database columns" "Convert a message map to database columns"
@@ -70,14 +91,18 @@
["SELECT * FROM sessions ORDER BY updated_at DESC"] ["SELECT * FROM sessions ORDER BY updated_at DESC"]
{:builder-fn rs/as-unqualified-kebab-maps})] {:builder-fn rs/as-unqualified-kebab-maps})]
(mapv (fn [row] (mapv (fn [row]
{:id (:id row) (cond-> {:id (:id row)
:provider (keyword (:provider row)) :provider (keyword (:provider row))
:external-id (:external-id row) :external-id (:external-id row)
:title (:title row) :title (:title row)
:working-dir (:working-dir row) :working-dir (:working-dir row)
:status (keyword (or (:status row) "idle")) :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) :created-at (:created-at row)
:updated-at (:updated-at row)}) :updated-at (:updated-at row)}
(:pending-permission row)
(assoc :pending-permission (json/read-value (:pending-permission row) mapper))))
rows))) rows)))
(get-session [_ id] (get-session [_ id]
@@ -85,14 +110,18 @@
["SELECT * FROM sessions WHERE id = ?" id] ["SELECT * FROM sessions WHERE id = ?" id]
{:builder-fn rs/as-unqualified-kebab-maps})] {:builder-fn rs/as-unqualified-kebab-maps})]
(when row (when row
{:id (:id row) (cond-> {:id (:id row)
:provider (keyword (:provider row)) :provider (keyword (:provider row))
:external-id (:external-id row) :external-id (:external-id row)
:title (:title row) :title (:title row)
:working-dir (:working-dir row) :working-dir (:working-dir row)
:status (keyword (or (:status row) "idle")) :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) :created-at (:created-at row)
:updated-at (:updated-at row)}))) :updated-at (:updated-at row)}
(:pending-permission row)
(assoc :pending-permission (json/read-value (:pending-permission row) mapper))))))
(save-session [this session] (save-session [this session]
(let [id (or (:id session) (generate-id)) (let [id (or (:id session) (generate-id))
@@ -154,17 +183,28 @@
:content (:content row) :content (:content row)
:metadata (when-let [m (:metadata row)] :metadata (when-let [m (:metadata row)]
(json/read-value m mapper)) (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 (def schema
"SQLite schema for spiceflow" "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, id TEXT PRIMARY KEY,
provider TEXT NOT NULL, provider TEXT NOT NULL,
external_id TEXT, external_id TEXT,
title TEXT, title TEXT,
working_dir 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')), created_at TEXT DEFAULT (datetime('now')),
updated_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_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_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! (defn init-schema!
"Initialize database schema" "Initialize database schema"
[datasource] [datasource]
(doseq [stmt schema] (doseq [stmt schema]
(jdbc/execute! datasource [stmt]))) (jdbc/execute! datasource [stmt]))
(seed-statuses! datasource)
(run-migrations! datasource)
(migrate-status-column! datasource))
(defn create-store (defn create-store
"Create a SQLite store with the given database path" "Create a SQLite store with the given database path"
+154 -38
View File
@@ -10,9 +10,6 @@
;; Active process handles for running sessions ;; Active process handles for running sessions
(defonce ^:private active-processes (ConcurrentHashMap.)) (defonce ^:private active-processes (ConcurrentHashMap.))
;; Pending permission requests: session-id -> {:tools [...] :denials [...]}
(defonce ^:private pending-permissions (ConcurrentHashMap.))
(defn get-adapter (defn get-adapter
"Get the appropriate adapter for a provider" "Get the appropriate adapter for a provider"
[provider] [provider]
@@ -35,22 +32,33 @@
(defn start-session (defn start-session
"Start a CLI process for a session" "Start a CLI process for a session"
[store session-id] [store session-id]
(log/debug "User action: start-session" {:session-id session-id})
(let [session (db/get-session store session-id)] (let [session (db/get-session store session-id)]
(when-not session (when-not session
(throw (ex-info "Session not found" {:session-id session-id}))) (throw (ex-info "Session not found" {:session-id session-id})))
(when (session-running? session-id) (when (session-running? session-id)
(throw (ex-info "Session already running" {:session-id 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)) (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 handle (adapter/spawn-session adapter
(:external-id session) (: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) (.put active-processes session-id handle)
(db/update-session store session-id {:status :running}) (db/update-session store session-id {:status :processing})
handle))) handle)))
(defn stop-session (defn stop-session
"Stop a running CLI process for a session" "Stop a running CLI process for a session"
[store session-id] [store session-id]
(log/debug "User action: stop-session" {:session-id session-id})
(when-let [handle (.remove active-processes session-id)] (when-let [handle (.remove active-processes session-id)]
(let [session (db/get-session store session-id) (let [session (db/get-session store session-id)
adapter (get-adapter (:provider session))] adapter (get-adapter (:provider session))]
@@ -60,6 +68,7 @@
(defn send-message-to-session (defn send-message-to-session
"Send a message to a running session" "Send a message to a running session"
[store session-id message] [store session-id message]
(log/debug "User action: send-message" {:session-id session-id :message message})
(let [session (db/get-session store session-id) (let [session (db/get-session store session-id)
_ (when-not session _ (when-not session
(throw (ex-info "Session not found" {:session-id session-id}))) (throw (ex-info "Session not found" {:session-id session-id})))
@@ -71,40 +80,88 @@
(db/save-message store {:session-id session-id (db/save-message store {:session-id session-id
:role :user :role :user
:content message}) :content message})
;; Send to CLI ;; Send to CLI - for OpenCode, this returns an updated handle with the process
(adapter/send-message adapter handle message))) (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 (defn set-pending-permission
"Store a pending permission request for a session" "Store a pending permission request for a session in the database"
[session-id permission-request] [store session-id permission-request]
(.put pending-permissions session-id permission-request)) (db/update-session store session-id {:pending-permission permission-request}))
(defn get-pending-permission (defn get-pending-permission
"Get pending permission request for a session" "Get pending permission request for a session from the database"
[session-id] [store session-id]
(.get pending-permissions session-id)) (:pending-permission (db/get-session store session-id)))
(defn clear-pending-permission (defn clear-pending-permission
"Clear pending permission for a session" "Clear pending permission for a session in the database"
[session-id] [store session-id]
(.remove pending-permissions 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 (defn stream-session-response
"Stream response from a running session, calling callback for each event" "Stream response from a running session, calling callback for each event"
[store session-id callback] [store session-id callback]
(log/info "stream-session-response starting for session:" session-id)
(let [session (db/get-session store session-id) (let [session (db/get-session store session-id)
_ (when-not session _ (when-not session
(throw (ex-info "Session not found" {:session-id session-id}))) (throw (ex-info "Session not found" {:session-id session-id})))
handle (get-active-process 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 _ (when-not handle
(throw (ex-info "Session not running" {:session-id session-id}))) (throw (ex-info "Session not running" {:session-id session-id})))
adapter (get-adapter (:provider session)) adapter (get-adapter (:provider session))
content-buffer (StringBuilder.)] content-buffer (StringBuilder.)
last-working-dir (atom nil)]
;; Read stream and accumulate content ;; Read stream and accumulate content
(log/info "Starting to read stream for session:" session-id)
(adapter/read-stream adapter handle (adapter/read-stream adapter handle
(fn [event] (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) (callback event)
;; Accumulate text content ;; Accumulate text content
(when-let [text (:text event)] (when-let [text (:text event)]
@@ -115,12 +172,24 @@
(not (:external-id session))) (not (:external-id session)))
(log/debug "Capturing external session-id:" (:session-id event)) (log/debug "Capturing external session-id:" (:session-id event))
(db/update-session store session-id {:external-id (:session-id event)})) (db/update-session store session-id {:external-id (:session-id event)}))
;; Also capture working directory from cwd ;; Capture spawn-dir from init cwd (only for new sessions that don't have it)
(when (and (:cwd event) ;; This is the directory where Claude's project is, used for resuming
(or (nil? (:working-dir session)) (when (and (:cwd event) (not (:spawn-dir session)))
(empty? (:working-dir session)))) (log/debug "Capturing spawn-dir from init:" (:cwd event))
(log/debug "Capturing working directory:" (:cwd event)) (db/update-session store session-id {:spawn-dir (:cwd event)}))
(db/update-session store session-id {:working-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 ;; Also capture external-id from result event if not already set
(when (and (= :result (:event event)) (when (and (= :result (:event event))
(:session-id event) (:session-id event)
@@ -129,21 +198,52 @@
(db/update-session store session-id {:external-id (:session-id event)})) (db/update-session store session-id {:external-id (:session-id event)}))
;; On result event, check for permission requests ;; On result event, check for permission requests
(when (= :result (:event event)) (when (= :result (:event event))
(let [content (.toString content-buffer)] ;; Use accumulated content, or fall back to result content
;; Save accumulated message if any ;; (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) (when (seq content)
(db/save-message store {:session-id session-id (db/save-message store {:session-id session-id
:role :assistant :role :assistant
:content content})) :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)] (when-let [perm-req (:permission-request event)]
(log/info "Permission request detected:" perm-req) (log/info "Permission request detected:" perm-req)
(set-pending-permission session-id 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 (callback {:event :permission-request
:permission-request perm-req})))))) :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 ;; Update session status when stream ends
;; If there's a pending permission, set status to awaiting-permission ;; 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 :awaiting-permission
:idle)] :idle)]
(db/update-session store session-id {:status new-status})) (db/update-session store session-id {:status new-status}))
@@ -163,18 +263,27 @@
response-type: :accept, :deny, or :steer response-type: :accept, :deny, or :steer
message: optional message for :deny or :steer responses" message: optional message for :deny or :steer responses"
[store session-id response-type message] [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) (let [session (db/get-session store session-id)
_ (when-not session _ (when-not session
(throw (ex-info "Session not found" {:session-id session-id}))) (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 _ (when-not pending
(throw (ex-info "No pending permission request" {:session-id session-id}))) (throw (ex-info "No pending permission request" {:session-id session-id})))
adapter (get-adapter (:provider session)) adapter (get-adapter (:provider session))
;; Build spawn options based on response type ;; Use spawn-dir for spawning, fall back to working-dir for existing sessions
opts (cond-> {:working-dir (:working-dir session)} spawn-dir (or (:spawn-dir session) (:working-dir session))
;; For :accept, grant the requested tools ;; Auto-accept tools from session setting (always included if enabled)
(= response-type :accept) auto-accept-tools (when (:auto-accept-edits session)
(assoc :allowed-tools (:tools pending))) ["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 ;; Determine the message to send
send-msg (case response-type send-msg (case response-type
:accept "continue" :accept "continue"
@@ -184,11 +293,18 @@
handle (adapter/spawn-session adapter handle (adapter/spawn-session adapter
(:external-id session) (:external-id session)
opts)] 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
(clear-pending-permission session-id) (clear-pending-permission store session-id)
;; Store new process handle ;; Store new process handle
(.put active-processes session-id 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 ;; Send the response message
(adapter/send-message adapter handle send-msg) (adapter/send-message adapter handle send-msg)
handle)) handle))
+1
View File
@@ -0,0 +1 @@
Hello from OpenCode test
+1
View File
@@ -0,0 +1 @@
Hello from OpenCode test