managed sessions only. allow for rename/delete
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Spiceflow is an AI Session Orchestration PWA for monitoring and interacting with Claude Code and OpenCode CLI sessions from mobile devices or web browsers. It's a monorepo with three main components: a Clojure backend server, a SvelteKit frontend, and Playwright E2E tests.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend (Clojure)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
clj -M:run # Start server (port 3000)
|
||||||
|
clj -M:test # Run tests with Kaocha
|
||||||
|
clj -M:repl # Start REPL with nREPL for interactive development
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (SvelteKit)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install # Install dependencies
|
||||||
|
npm run dev # Start dev server (port 5173, proxies /api to 3000)
|
||||||
|
npm run build # Production build
|
||||||
|
npm run check # Type checking with svelte-check
|
||||||
|
npm run check:watch # Watch mode for type checking
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests (Playwright)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e
|
||||||
|
npm test # Run all tests (starts both servers automatically)
|
||||||
|
npm run test:headed # Run tests with visible browser
|
||||||
|
npm run test:ui # Interactive Playwright UI mode
|
||||||
|
```
|
||||||
|
|
||||||
|
E2E tests use a separate database (`server/test-e2e.db`).
|
||||||
|
|
||||||
|
### Development Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./script/dev # Start backend + frontend concurrently
|
||||||
|
./script/test # Start servers and run E2E tests
|
||||||
|
```
|
||||||
|
|
||||||
|
The `dev` script starts both servers and waits for each to be ready before proceeding. The `test` script uses a separate test database and cleans up after tests complete.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude Code/OpenCode CLI ↔ Spiceflow Server (Clojure) ↔ PWA Client (SvelteKit)
|
||||||
|
↓
|
||||||
|
SQLite DB
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (`/server`)
|
||||||
|
|
||||||
|
- **Entry point**: `src/spiceflow/core.clj` - Ring/Jetty server with mount lifecycle
|
||||||
|
- **Routing**: `src/spiceflow/api/routes.clj` - Reitit-based REST API
|
||||||
|
- **Database**: Protocol-based abstraction (`db/protocol.clj`) with SQLite (`db/sqlite.clj`) and in-memory (`db/memory.clj`) implementations
|
||||||
|
- **Adapters**: Pluggable CLI integrations (`adapters/protocol.clj`) - Claude Code (`adapters/claude.clj`) and OpenCode (`adapters/opencode.clj`)
|
||||||
|
- **WebSocket**: `api/websocket.clj` - Real-time message streaming
|
||||||
|
- **Session management**: `session/manager.clj` - Session lifecycle
|
||||||
|
|
||||||
|
### Frontend (`/client`)
|
||||||
|
|
||||||
|
- **Routes**: SvelteKit file-based routing in `src/routes/`
|
||||||
|
- **State**: Svelte stores in `src/lib/stores/` (sessions, runtime selection)
|
||||||
|
- **API client**: `src/lib/api.ts` - HTTP and WebSocket clients
|
||||||
|
- **Components**: `src/lib/components/` - UI components
|
||||||
|
- **PWA**: vite-plugin-pwa with Workbox service worker
|
||||||
|
|
||||||
|
### Key Protocols
|
||||||
|
|
||||||
|
**DataStore** (`db/protocol.clj`):
|
||||||
|
- `get-sessions`, `get-session`, `save-session`, `update-session`, `delete-session`
|
||||||
|
- `get-messages`, `save-message`
|
||||||
|
|
||||||
|
**AgentAdapter** (`adapters/protocol.clj`):
|
||||||
|
- `discover` - Find existing CLI sessions
|
||||||
|
- `spawn` - Start CLI process with session
|
||||||
|
- `send` - Pipe message to stdin
|
||||||
|
- `read-stream` - Parse JSONL output
|
||||||
|
- Adding new runtimes requires implementing this protocol
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Server configuration via `server/resources/config.edn` or environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SPICEFLOW_PORT` | 3000 | Server port |
|
||||||
|
| `SPICEFLOW_HOST` | 0.0.0.0 | Server host |
|
||||||
|
| `SPICEFLOW_DB` | spiceflow.db | SQLite database path |
|
||||||
|
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory |
|
||||||
|
| `OPENCODE_CMD` | opencode | OpenCode command |
|
||||||
|
|
||||||
|
## Session Flow
|
||||||
|
|
||||||
|
1. User opens PWA → sees list of tracked sessions
|
||||||
|
2. User selects session → loads message history
|
||||||
|
3. User types message → POST to `/api/sessions/:id/send`
|
||||||
|
4. Server spawns CLI process with `--resume` flag
|
||||||
|
5. Server pipes user message to stdin
|
||||||
|
6. CLI streams response via stdout (JSONL format)
|
||||||
|
7. Server broadcasts to client via WebSocket
|
||||||
|
8. Process completes → response saved to database
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Clojure 1.11, Ring/Jetty, Reitit, next.jdbc, SQLite, mount, Kaocha
|
||||||
|
- **Frontend**: SvelteKit 2.5, Svelte 4, TypeScript, Tailwind CSS, Vite, vite-plugin-pwa
|
||||||
|
- **E2E**: Playwright
|
||||||
Generated
+36
-9
@@ -12,6 +12,7 @@
|
|||||||
"@sveltejs/kit": "^2.5.0",
|
"@sveltejs/kit": "^2.5.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||||
"autoprefixer": "^10.4.17",
|
"autoprefixer": "^10.4.17",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.4.33",
|
||||||
"svelte": "^4.2.9",
|
"svelte": "^4.2.9",
|
||||||
@@ -2755,6 +2756,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitejs/plugin-basic-ssl": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -5453,9 +5467,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss-load-config": {
|
"node_modules/postcss-load-config": {
|
||||||
"version": "6.0.1",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-5.1.0.tgz",
|
||||||
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
"integrity": "sha512-G5AJ+IX0aD0dygOE0yFZQ/huFFMSNneyfp0e3/bT05a8OfPC5FUoZRPfGijUdGOJNMewJiwzcHJXFafFzeKFVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5470,7 +5484,8 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lilconfig": "^3.1.1"
|
"lilconfig": "^3.1.1",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -5478,8 +5493,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"postcss": ">=8.0.9",
|
"postcss": ">=8.0.9",
|
||||||
"tsx": "^4.8.1",
|
"tsx": "^4.8.1"
|
||||||
"yaml": "^2.4.2"
|
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"jiti": {
|
"jiti": {
|
||||||
@@ -5490,9 +5504,6 @@
|
|||||||
},
|
},
|
||||||
"tsx": {
|
"tsx": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
|
||||||
"yaml": {
|
|
||||||
"optional": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7804,6 +7815,22 @@
|
|||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -15,6 +15,7 @@
|
|||||||
"@sveltejs/kit": "^2.5.0",
|
"@sveltejs/kit": "^2.5.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||||
"autoprefixer": "^10.4.17",
|
"autoprefixer": "^10.4.17",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.4.33",
|
||||||
"svelte": "^4.2.9",
|
"svelte": "^4.2.9",
|
||||||
@@ -25,6 +26,5 @@
|
|||||||
"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": {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-19
@@ -27,12 +27,15 @@ export interface Message {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoveredSession {
|
export interface PermissionDenial {
|
||||||
'external-id': string;
|
tool: string;
|
||||||
provider: 'claude' | 'opencode';
|
input: Record<string, unknown>;
|
||||||
title?: string;
|
description: string;
|
||||||
'working-dir'?: string;
|
}
|
||||||
'file-path'?: string;
|
|
||||||
|
export interface PermissionRequest {
|
||||||
|
tools: string[];
|
||||||
|
denials: PermissionDenial[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamEvent {
|
export interface StreamEvent {
|
||||||
@@ -43,6 +46,9 @@ export interface StreamEvent {
|
|||||||
content?: string;
|
content?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
cwd?: string;
|
||||||
|
'permission-request'?: PermissionRequest;
|
||||||
|
permissionRequest?: PermissionRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -93,6 +99,13 @@ class ApiClient {
|
|||||||
await this.request<void>(`/sessions/${id}`, { method: 'DELETE' });
|
await this.request<void>(`/sessions/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSession(id: string, data: Partial<Session>): Promise<Session> {
|
||||||
|
return this.request<Session>(`/sessions/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async sendMessage(sessionId: string, message: string): Promise<{ status: string }> {
|
async sendMessage(sessionId: string, message: string): Promise<{ status: string }> {
|
||||||
return this.request<{ status: string }>(`/sessions/${sessionId}/send`, {
|
return this.request<{ status: string }>(`/sessions/${sessionId}/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -100,19 +113,14 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discovery
|
async respondToPermission(
|
||||||
async discoverClaude(): Promise<DiscoveredSession[]> {
|
sessionId: string,
|
||||||
return this.request<DiscoveredSession[]>('/discover/claude');
|
response: 'accept' | 'deny' | 'steer',
|
||||||
}
|
message?: string
|
||||||
|
): Promise<{ status: string }> {
|
||||||
async discoverOpenCode(): Promise<DiscoveredSession[]> {
|
return this.request<{ status: string }>(`/sessions/${sessionId}/permission`, {
|
||||||
return this.request<DiscoveredSession[]>('/discover/opencode');
|
|
||||||
}
|
|
||||||
|
|
||||||
async importSession(session: DiscoveredSession): Promise<Session> {
|
|
||||||
return this.request<Session>('/import', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(session)
|
body: JSON.stringify({ response, message })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +142,7 @@ export class WebSocketClient {
|
|||||||
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();
|
||||||
|
|
||||||
constructor(url: string = `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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
textarea.style.height = 'auto';
|
textarea.style.height = 'auto';
|
||||||
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
|
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function focus() {
|
||||||
|
textarea?.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export let messages: Message[] = [];
|
export let messages: Message[] = [];
|
||||||
export let streamingContent: string = '';
|
export let streamingContent: string = '';
|
||||||
|
export let isThinking: boolean = false;
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let autoScroll = true;
|
let autoScroll = true;
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
on:scroll={handleScroll}
|
on:scroll={handleScroll}
|
||||||
class="flex-1 overflow-y-auto px-4 py-4 space-y-4"
|
class="flex-1 overflow-y-auto px-4 py-4 space-y-4"
|
||||||
>
|
>
|
||||||
{#if messages.length === 0 && !streamingContent}
|
{#if messages.length === 0 && !streamingContent && !isThinking}
|
||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<div class="text-center text-zinc-500">
|
<div class="text-center text-zinc-500">
|
||||||
<svg
|
<svg
|
||||||
@@ -87,7 +88,26 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if streamingContent}
|
{#if isThinking && !streamingContent}
|
||||||
|
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||||
|
Assistant
|
||||||
|
</span>
|
||||||
|
<span class="flex gap-1">
|
||||||
|
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||||
|
style="animation-delay: 0.1s"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||||
|
style="animation-delay: 0.2s"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{: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="flex items-center gap-2 mb-2">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import type { PermissionRequest } from '$lib/api';
|
||||||
|
|
||||||
|
export let permission: PermissionRequest;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
accept: void;
|
||||||
|
deny: void;
|
||||||
|
steer: void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleAccept() {
|
||||||
|
dispatch('accept');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeny() {
|
||||||
|
dispatch('deny');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSteer() {
|
||||||
|
dispatch('steer');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="border-t border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-shrink-0 mt-0.5">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-amber-200">Claude needs permission:</p>
|
||||||
|
|
||||||
|
<ul class="mt-2 space-y-1">
|
||||||
|
{#each permission.denials as denial}
|
||||||
|
<li class="text-sm text-zinc-300 font-mono truncate">
|
||||||
|
<span class="text-amber-400">{denial.tool}:</span>
|
||||||
|
{denial.description}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
on:click={handleAccept}
|
||||||
|
class="btn bg-green-600 hover:bg-green-500 text-white text-sm px-3 py-1.5"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={handleDeny}
|
||||||
|
class="btn bg-red-600 hover:bg-red-500 text-white text-sm px-3 py-1.5"
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={handleSteer}
|
||||||
|
class="btn bg-zinc-600 hover:bg-zinc-500 text-white text-sm px-3 py-1.5"
|
||||||
|
>
|
||||||
|
No, and...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
import type { Session } from '$lib/api';
|
import type { Session } from '$lib/api';
|
||||||
|
|
||||||
export let session: Session;
|
export let session: Session;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ delete: void }>();
|
||||||
|
|
||||||
|
function handleDelete(event: MouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
dispatch('delete');
|
||||||
|
}
|
||||||
|
|
||||||
$: externalId = session['external-id'] || session.externalId || '';
|
$: externalId = session['external-id'] || session.externalId || '';
|
||||||
$: 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 || '';
|
||||||
@@ -60,8 +69,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-right flex-shrink-0">
|
<div class="text-right flex-shrink-0 flex items-center gap-2">
|
||||||
<span class="text-xs text-zinc-500">{formatTime(updatedAt)}</span>
|
<span class="text-xs text-zinc-500">{formatTime(updatedAt)}</span>
|
||||||
|
<button
|
||||||
|
on:click={handleDelete}
|
||||||
|
class="p-1 text-zinc-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||||
|
title="Delete session"
|
||||||
|
>
|
||||||
|
<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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { writable, derived, type Readable } from 'svelte/store';
|
import { writable, derived, type Readable } from 'svelte/store';
|
||||||
import { api, wsClient, type Session, type Message, type StreamEvent } from '$lib/api';
|
import { api, wsClient, type Session, type Message, type StreamEvent, type PermissionRequest } from '$lib/api';
|
||||||
|
|
||||||
interface SessionsState {
|
interface SessionsState {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
@@ -11,8 +11,10 @@ interface ActiveSessionState {
|
|||||||
session: Session | null;
|
session: Session | null;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
streamingContent: string;
|
streamingContent: string;
|
||||||
|
isThinking: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
pendingPermission: PermissionRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionsStore() {
|
function createSessionsStore() {
|
||||||
@@ -59,6 +61,21 @@ function createSessionsStore() {
|
|||||||
session.id === id ? { ...session, ...data } : session
|
session.id === id ? { ...session, ...data } : session
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
},
|
||||||
|
async rename(id: string, title: string) {
|
||||||
|
try {
|
||||||
|
const updated = await api.updateSession(id, { title });
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
sessions: s.sessions.map((session) =>
|
||||||
|
session.id === id ? { ...session, ...updated } : session
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
return updated;
|
||||||
|
} catch (e) {
|
||||||
|
update((s) => ({ ...s, error: (e as Error).message }));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -68,8 +85,10 @@ function createActiveSessionStore() {
|
|||||||
session: null,
|
session: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
streamingContent: '',
|
streamingContent: '',
|
||||||
|
isThinking: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null
|
error: null,
|
||||||
|
pendingPermission: null
|
||||||
});
|
});
|
||||||
|
|
||||||
let unsubscribeWs: (() => void) | null = null;
|
let unsubscribeWs: (() => void) | null = null;
|
||||||
@@ -119,7 +138,8 @@ function createActiveSessionStore() {
|
|||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
messages: [...s.messages, userMessage],
|
messages: [...s.messages, userMessage],
|
||||||
streamingContent: ''
|
streamingContent: '',
|
||||||
|
isThinking: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -150,6 +170,37 @@ function createActiveSessionStore() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async respondToPermission(response: 'accept' | 'deny' | 'steer', message?: string) {
|
||||||
|
const state = get();
|
||||||
|
if (!state.session || !state.pendingPermission) return;
|
||||||
|
|
||||||
|
// Clear pending permission immediately
|
||||||
|
update((s) => ({ ...s, pendingPermission: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.respondToPermission(state.session.id, response, message);
|
||||||
|
} catch (e) {
|
||||||
|
update((s) => ({ ...s, error: (e as Error).message }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async rename(title: string) {
|
||||||
|
const state = get();
|
||||||
|
if (!state.session) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await api.updateSession(state.session.id, { title });
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
session: s.session ? { ...s.session, ...updated } : null
|
||||||
|
}));
|
||||||
|
// Also update in the sessions list
|
||||||
|
sessions.updateSession(state.session.id, { title });
|
||||||
|
return updated;
|
||||||
|
} catch (e) {
|
||||||
|
update((s) => ({ ...s, error: (e as Error).message }));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
clear() {
|
clear() {
|
||||||
if (unsubscribeWs) {
|
if (unsubscribeWs) {
|
||||||
unsubscribeWs();
|
unsubscribeWs();
|
||||||
@@ -159,8 +210,10 @@ function createActiveSessionStore() {
|
|||||||
session: null,
|
session: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
streamingContent: '',
|
streamingContent: '',
|
||||||
|
isThinking: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null
|
error: null,
|
||||||
|
pendingPermission: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -172,11 +225,20 @@ function createActiveSessionStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleStreamEvent(event: StreamEvent) {
|
function handleStreamEvent(event: StreamEvent) {
|
||||||
if (event.event === 'content-delta' && event.text) {
|
if (event.event === 'init' && event.cwd) {
|
||||||
update((s) => ({ ...s, streamingContent: s.streamingContent + event.text }));
|
// Update session's working directory from init event
|
||||||
|
update((s) => {
|
||||||
|
if (!s.session) return s;
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
session: { ...s.session, 'working-dir': event.cwd, workingDir: event.cwd }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (event.event === 'content-delta' && event.text) {
|
||||||
|
update((s) => ({ ...s, streamingContent: s.streamingContent + event.text, isThinking: false }));
|
||||||
} else if (event.event === 'message-stop') {
|
} else if (event.event === 'message-stop') {
|
||||||
update((s) => {
|
update((s) => {
|
||||||
if (!s.streamingContent || !s.session) return s;
|
if (!s.streamingContent || !s.session) return { ...s, isThinking: false };
|
||||||
|
|
||||||
const assistantMessage: Message = {
|
const assistantMessage: Message = {
|
||||||
id: `stream-${Date.now()}`,
|
id: `stream-${Date.now()}`,
|
||||||
@@ -189,11 +251,17 @@ function createActiveSessionStore() {
|
|||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
messages: [...s.messages, assistantMessage],
|
messages: [...s.messages, assistantMessage],
|
||||||
streamingContent: ''
|
streamingContent: '',
|
||||||
|
isThinking: false
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
} else if (event.event === 'permission-request') {
|
||||||
|
const permReq = event['permission-request'] || event.permissionRequest;
|
||||||
|
if (permReq) {
|
||||||
|
update((s) => ({ ...s, pendingPermission: permReq }));
|
||||||
|
}
|
||||||
} else if (event.event === 'error') {
|
} else if (event.event === 'error') {
|
||||||
update((s) => ({ ...s, error: event.message || 'Stream error' }));
|
update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-146
@@ -1,22 +1,21 @@
|
|||||||
<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, runningSessions } from '$lib/stores/sessions';
|
||||||
import { api, type DiscoveredSession } from '$lib/api';
|
import type { Session } from '$lib/api';
|
||||||
import SessionCard from '$lib/components/SessionCard.svelte';
|
import SessionCard from '$lib/components/SessionCard.svelte';
|
||||||
|
|
||||||
let discovering = false;
|
let showNewSessionMenu = false;
|
||||||
let discoveredSessions: DiscoveredSession[] = [];
|
|
||||||
let showDiscovery = false;
|
|
||||||
let creating = false;
|
let creating = false;
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
await sessions.load();
|
await sessions.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createNewSession() {
|
async function createNewSession(provider: Session['provider']) {
|
||||||
creating = true;
|
creating = true;
|
||||||
|
showNewSessionMenu = false;
|
||||||
try {
|
try {
|
||||||
const session = await sessions.create({ provider: 'claude' });
|
const session = await sessions.create({ provider });
|
||||||
await goto(`/session/${session.id}`);
|
await goto(`/session/${session.id}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to create session:', e);
|
console.error('Failed to create session:', e);
|
||||||
@@ -25,28 +24,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoverSessions() {
|
async function deleteSession(id: string) {
|
||||||
discovering = true;
|
if (confirm('Delete this session?')) {
|
||||||
try {
|
await sessions.delete(id);
|
||||||
const [claude, opencode] = await Promise.all([
|
|
||||||
api.discoverClaude().catch(() => []),
|
|
||||||
api.discoverOpenCode().catch(() => [])
|
|
||||||
]);
|
|
||||||
discoveredSessions = [...claude, ...opencode];
|
|
||||||
showDiscovery = true;
|
|
||||||
} finally {
|
|
||||||
discovering = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importSession(session: DiscoveredSession) {
|
|
||||||
await api.importSession(session);
|
|
||||||
discoveredSessions = discoveredSessions.filter(
|
|
||||||
(s) => s['external-id'] !== session['external-id']
|
|
||||||
);
|
|
||||||
await sessions.load();
|
|
||||||
if (discoveredSessions.length === 0) {
|
|
||||||
showDiscovery = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -55,6 +35,8 @@
|
|||||||
<title>Spiceflow</title>
|
<title>Spiceflow</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<svelte:window on:click={() => (showNewSessionMenu = false)} />
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
|
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -64,23 +46,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<div class="relative">
|
||||||
on:click={createNewSession}
|
<button
|
||||||
disabled={creating}
|
on:click|stopPropagation={() => (showNewSessionMenu = !showNewSessionMenu)}
|
||||||
class="btn btn-primary p-2"
|
disabled={creating}
|
||||||
title="New Session"
|
class="btn btn-primary p-2"
|
||||||
>
|
title="New Session"
|
||||||
{#if creating}
|
>
|
||||||
<svg class="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
{#if creating}
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<svg class="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
</svg>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
{:else}
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
{:else}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showNewSessionMenu}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl z-50 min-w-[140px] overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
on:click={() => createNewSession('claude')}
|
||||||
|
class="w-full px-4 py-2.5 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-spice-500"></span>
|
||||||
|
Claude Code
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => createNewSession('opencode')}
|
||||||
|
class="w-full px-4 py-2.5 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||||
|
OpenCode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
on:click={refresh}
|
on:click={refresh}
|
||||||
@@ -103,33 +108,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
on:click={discoverSessions}
|
|
||||||
disabled={discovering}
|
|
||||||
class="btn btn-secondary"
|
|
||||||
>
|
|
||||||
{#if discovering}
|
|
||||||
<span class="animate-spin inline-block mr-2">
|
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
Discover
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -190,88 +168,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h2 class="text-lg font-medium text-zinc-300 mb-2">No sessions yet</h2>
|
<h2 class="text-lg font-medium text-zinc-300 mb-2">No sessions yet</h2>
|
||||||
<p class="text-sm mb-4">
|
<p class="text-sm mb-4">
|
||||||
Click the + button to start a new chat, or "Discover" to find existing Claude Code sessions.
|
Click the + button to start a new session.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="p-4 space-y-3">
|
<div class="p-4 space-y-3">
|
||||||
{#each $sortedSessions as session (session.id)}
|
{#each $sortedSessions as session (session.id)}
|
||||||
<SessionCard {session} />
|
<SessionCard {session} on:delete={() => deleteSession(session.id)} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Discovery Modal -->
|
|
||||||
{#if showDiscovery}
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center"
|
|
||||||
on:click={() => (showDiscovery = false)}
|
|
||||||
on:keydown={(e) => e.key === 'Escape' && (showDiscovery = false)}
|
|
||||||
role="dialog"
|
|
||||||
tabindex="-1"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="bg-zinc-900 w-full sm:max-w-lg sm:rounded-xl rounded-t-xl border border-zinc-700 max-h-[80vh] flex flex-col"
|
|
||||||
on:click|stopPropagation
|
|
||||||
on:keydown|stopPropagation
|
|
||||||
role="document"
|
|
||||||
>
|
|
||||||
<div class="p-4 border-b border-zinc-700 flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold">Discovered Sessions</h2>
|
|
||||||
<button
|
|
||||||
on:click={() => (showDiscovery = false)}
|
|
||||||
class="p-1 hover:bg-zinc-700 rounded"
|
|
||||||
>
|
|
||||||
<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="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-4">
|
|
||||||
{#if discoveredSessions.length === 0}
|
|
||||||
<p class="text-zinc-500 text-center py-8">No new sessions found.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each discoveredSessions as session}
|
|
||||||
<div class="card flex items-center justify-between">
|
|
||||||
<div class="flex-1 min-w-0 mr-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="text-xs font-medium uppercase {session.provider === 'claude'
|
|
||||||
? 'text-spice-400'
|
|
||||||
: 'text-emerald-400'}"
|
|
||||||
>
|
|
||||||
{session.provider}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-zinc-300 truncate">
|
|
||||||
{session.title || session['external-id'].slice(0, 8)}
|
|
||||||
</p>
|
|
||||||
{#if session['working-dir']}
|
|
||||||
<p class="text-xs text-zinc-500 truncate">
|
|
||||||
{session['working-dir']}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
on:click={() => importSession(session)}
|
|
||||||
class="btn btn-primary text-sm py-1.5"
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { activeSession } from '$lib/stores/sessions';
|
import { activeSession } from '$lib/stores/sessions';
|
||||||
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';
|
||||||
|
|
||||||
$: sessionId = $page.params.id;
|
$: sessionId = $page.params.id;
|
||||||
|
|
||||||
|
let inputBar: InputBar;
|
||||||
|
let steerMode = false;
|
||||||
|
let isEditingTitle = false;
|
||||||
|
let editedTitle = '';
|
||||||
|
let titleInput: HTMLInputElement;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
activeSession.load(sessionId);
|
activeSession.load(sessionId);
|
||||||
@@ -19,13 +26,64 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleSend(event: CustomEvent<string>) {
|
function handleSend(event: CustomEvent<string>) {
|
||||||
activeSession.sendMessage(event.detail);
|
if (steerMode && $activeSession.pendingPermission) {
|
||||||
|
// Send as steer response
|
||||||
|
activeSession.respondToPermission('steer', event.detail);
|
||||||
|
steerMode = false;
|
||||||
|
} else {
|
||||||
|
activeSession.sendMessage(event.detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePermissionAccept() {
|
||||||
|
activeSession.respondToPermission('accept');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePermissionDeny() {
|
||||||
|
activeSession.respondToPermission('deny');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePermissionSteer() {
|
||||||
|
steerMode = true;
|
||||||
|
// Focus the input bar
|
||||||
|
inputBar?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
goto('/');
|
goto('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startEditingTitle() {
|
||||||
|
if (!session) return;
|
||||||
|
editedTitle = session.title || '';
|
||||||
|
isEditingTitle = true;
|
||||||
|
await tick();
|
||||||
|
titleInput?.focus();
|
||||||
|
titleInput?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTitle() {
|
||||||
|
if (!session || !isEditingTitle) return;
|
||||||
|
const newTitle = editedTitle.trim();
|
||||||
|
isEditingTitle = false;
|
||||||
|
if (newTitle !== (session.title || '')) {
|
||||||
|
await activeSession.rename(newTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditTitle() {
|
||||||
|
isEditingTitle = false;
|
||||||
|
editedTitle = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTitleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
saveTitle();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
cancelEditTitle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$: session = $activeSession.session;
|
$: session = $activeSession.session;
|
||||||
$: externalId = session?.['external-id'] || session?.externalId || '';
|
$: externalId = session?.['external-id'] || session?.externalId || '';
|
||||||
$: workingDir = session?.['working-dir'] || session?.workingDir || '';
|
$: workingDir = session?.['working-dir'] || session?.workingDir || '';
|
||||||
@@ -66,9 +124,24 @@
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-2 h-2 rounded-full {statusColors[session.status] || statusColors.idle}"></span>
|
<span class="w-2 h-2 rounded-full {statusColors[session.status] || statusColors.idle}"></span>
|
||||||
<h1 class="font-semibold truncate">
|
{#if isEditingTitle}
|
||||||
{session.title || `Session ${shortId}`}
|
<input
|
||||||
</h1>
|
bind:this={titleInput}
|
||||||
|
bind:value={editedTitle}
|
||||||
|
on:blur={saveTitle}
|
||||||
|
on:keydown={handleTitleKeydown}
|
||||||
|
class="font-semibold bg-zinc-800 border border-zinc-600 rounded px-2 py-0.5 text-zinc-100 focus:outline-none focus:border-spice-500 w-full max-w-[200px]"
|
||||||
|
placeholder="Session name"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
on:click={startEditingTitle}
|
||||||
|
class="font-semibold truncate text-left hover:text-spice-400 transition-colors"
|
||||||
|
title="Click to rename"
|
||||||
|
>
|
||||||
|
{session.title || `Session ${shortId}`}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if projectName}
|
{#if projectName}
|
||||||
<p class="text-xs text-zinc-500 truncate">{projectName}</p>
|
<p class="text-xs text-zinc-500 truncate">{projectName}</p>
|
||||||
@@ -107,11 +180,33 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<MessageList messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} />
|
{#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">
|
||||||
|
<svg class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="truncate font-mono">{workingDir}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<MessageList messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} />
|
||||||
|
|
||||||
|
{#if $activeSession.pendingPermission}
|
||||||
|
<PermissionRequest
|
||||||
|
permission={$activeSession.pendingPermission}
|
||||||
|
on:accept={handlePermissionAccept}
|
||||||
|
on:deny={handlePermissionDeny}
|
||||||
|
on:steer={handlePermissionSteer}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<InputBar
|
<InputBar
|
||||||
|
bind:this={inputBar}
|
||||||
on:send={handleSend}
|
on:send={handleSend}
|
||||||
disabled={session?.status === 'running' && $activeSession.streamingContent !== ''}
|
disabled={session?.status === 'running' && $activeSession.streamingContent !== ''}
|
||||||
placeholder={session?.status === 'running' ? 'Waiting for response...' : 'Type a message...'}
|
placeholder={steerMode
|
||||||
|
? 'Tell Claude what to do instead...'
|
||||||
|
: session?.status === 'running'
|
||||||
|
? 'Waiting for response...'
|
||||||
|
: 'Type a message...'}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import basicSsl from '@vitejs/plugin-basic-ssl';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
sveltekit(),
|
sveltekit(),
|
||||||
|
basicSsl(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
||||||
@@ -59,6 +61,7 @@ export default defineConfig({
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3000',
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Spiceflow E2E tests - Playwright-based end-to-end tests for the Spiceflow AI Session Orchestration PWA. Tests run against both the Clojure backend and SvelteKit frontend.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests (starts both servers automatically)
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run with visible browser
|
||||||
|
npm run test:headed
|
||||||
|
|
||||||
|
# Run with Playwright UI mode
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The e2e setup automatically manages both servers:
|
||||||
|
|
||||||
|
1. **Global Setup** (`global-setup.ts`) - Starts backend (port 3000) and frontend (port 5173)
|
||||||
|
2. **Global Teardown** (`global-teardown.ts`) - Stops both servers
|
||||||
|
3. **Server Utils** (`server-utils.ts`) - Spawns Clojure and Vite processes, waits for health checks
|
||||||
|
|
||||||
|
Tests use Playwright's `page` fixture for browser interactions and `request` fixture for direct API calls.
|
||||||
|
|
||||||
|
## Test Database
|
||||||
|
|
||||||
|
E2E tests use a separate database (`server/test-e2e.db`) to avoid polluting the main database.
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('example', async ({ page, request }) => {
|
||||||
|
// Direct API call
|
||||||
|
const response = await request.get('http://localhost:3000/api/sessions');
|
||||||
|
|
||||||
|
// Browser interaction
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('h1')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parent Project
|
||||||
|
|
||||||
|
This is part of the Spiceflow monorepo. See `../CLAUDE.md` for full project documentation including:
|
||||||
|
- `../server/` - Clojure backend (Ring/Reitit, SQLite)
|
||||||
|
- `../client/` - SvelteKit PWA frontend
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { startServers } from './server-utils.js';
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
// Skip if servers are managed externally (e.g., by scripts/test)
|
||||||
|
if (process.env.SKIP_SERVER_SETUP) {
|
||||||
|
console.log('\n=== Skipping server setup (SKIP_SERVER_SETUP is set) ===\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('\n=== Starting E2E Test Environment ===\n');
|
||||||
|
await startServers(3000, 5173);
|
||||||
|
console.log('\n=== E2E Test Environment Ready ===\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { stopServers } from './server-utils.js';
|
||||||
|
|
||||||
|
export default async function globalTeardown() {
|
||||||
|
// Skip if servers are managed externally (e.g., by scripts/test)
|
||||||
|
if (process.env.SKIP_SERVER_SETUP) {
|
||||||
|
console.log('\n=== Skipping server teardown (SKIP_SERVER_SETUP is set) ===\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('\n=== Stopping E2E Test Environment ===\n');
|
||||||
|
stopServers();
|
||||||
|
console.log('\n=== E2E Test Environment Stopped ===\n');
|
||||||
|
}
|
||||||
Generated
+302
@@ -0,0 +1,302 @@
|
|||||||
|
{
|
||||||
|
"name": "e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.57.0",
|
||||||
|
"@types/node": "^25.0.9",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cspotcode/source-map-support": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "0.3.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||||
|
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.0.3",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.57.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
|
||||||
|
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.57.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tsconfig/node10": {
|
||||||
|
"version": "1.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||||
|
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@tsconfig/node12": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@tsconfig/node14": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@tsconfig/node16": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz",
|
||||||
|
"integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/acorn": {
|
||||||
|
"version": "8.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/acorn-walk": {
|
||||||
|
"version": "8.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||||
|
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"acorn": "^8.11.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/arg": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/create-require": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/diff": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-error": {
|
||||||
|
"version": "1.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||||
|
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.57.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||||
|
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.57.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.57.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||||
|
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ts-node": {
|
||||||
|
"version": "10.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||||
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
|
"@tsconfig/node10": "^1.0.7",
|
||||||
|
"@tsconfig/node12": "^1.0.7",
|
||||||
|
"@tsconfig/node14": "^1.0.0",
|
||||||
|
"@tsconfig/node16": "^1.0.2",
|
||||||
|
"acorn": "^8.4.1",
|
||||||
|
"acorn-walk": "^8.1.1",
|
||||||
|
"arg": "^4.1.0",
|
||||||
|
"create-require": "^1.1.0",
|
||||||
|
"diff": "^4.0.1",
|
||||||
|
"make-error": "^1.1.1",
|
||||||
|
"v8-compile-cache-lib": "^3.0.1",
|
||||||
|
"yn": "3.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ts-node": "dist/bin.js",
|
||||||
|
"ts-node-cwd": "dist/bin-cwd.js",
|
||||||
|
"ts-node-esm": "dist/bin-esm.js",
|
||||||
|
"ts-node-script": "dist/bin-script.js",
|
||||||
|
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||||
|
"ts-script": "dist/bin-script-deprecated.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@swc/core": ">=1.2.50",
|
||||||
|
"@swc/wasm": ">=1.2.50",
|
||||||
|
"@types/node": "*",
|
||||||
|
"typescript": ">=2.7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@swc/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@swc/wasm": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/v8-compile-cache-lib": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/yn": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "spiceflow-e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "E2E tests for Spiceflow",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "npx playwright test",
|
||||||
|
"test:ui": "npx playwright test --ui",
|
||||||
|
"test:headed": "npx playwright test --headed"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.57.0",
|
||||||
|
"@types/node": "^25.0.9",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: 'list',
|
||||||
|
timeout: 30000,
|
||||||
|
use: {
|
||||||
|
baseURL: 'https://localhost:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
globalSetup: './global-setup.ts',
|
||||||
|
globalTeardown: './global-teardown.ts',
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { spawn, ChildProcess, execSync } from 'child_process';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const ROOT_DIR = resolve(__dirname, '..');
|
||||||
|
const SERVER_DIR = resolve(ROOT_DIR, 'server');
|
||||||
|
const CLIENT_DIR = resolve(ROOT_DIR, 'client');
|
||||||
|
|
||||||
|
export interface ServerProcesses {
|
||||||
|
backend: ChildProcess | null;
|
||||||
|
frontend: ChildProcess | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let processes: ServerProcesses = {
|
||||||
|
backend: null,
|
||||||
|
frontend: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function waitForServer(url: string, timeout = 30000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
// Allow self-signed certificates for HTTPS
|
||||||
|
const originalTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||||
|
if (url.startsWith('https://')) {
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cleanup = () => {
|
||||||
|
if (originalTlsReject !== undefined) {
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalTlsReject;
|
||||||
|
} else {
|
||||||
|
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (response.ok) {
|
||||||
|
// Add small delay to ensure server is fully ready
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server not ready yet
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - start > timeout) {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`Server at ${url} did not start within ${timeout}ms`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(check, 500);
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startBackend(port = 3000): Promise<ChildProcess> {
|
||||||
|
console.log('Starting backend server...');
|
||||||
|
|
||||||
|
// Use a test database to avoid polluting the main one
|
||||||
|
const testDbPath = resolve(SERVER_DIR, 'test-e2e.db');
|
||||||
|
|
||||||
|
// Remove old test database
|
||||||
|
try {
|
||||||
|
execSync(`rm -f ${testDbPath}`);
|
||||||
|
} catch {
|
||||||
|
// Ignore if doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = spawn('clj', ['-M:run'], {
|
||||||
|
cwd: SERVER_DIR,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
SPICEFLOW_PORT: String(port),
|
||||||
|
SPICEFLOW_DB: testDbPath,
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
backend.stdout?.on('data', (data) => {
|
||||||
|
console.log(`[backend] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
backend.stderr?.on('data', (data) => {
|
||||||
|
console.error(`[backend] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
processes.backend = backend;
|
||||||
|
|
||||||
|
await waitForServer(`http://localhost:${port}/api/health`);
|
||||||
|
console.log('Backend server ready');
|
||||||
|
|
||||||
|
return backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startFrontend(port = 5173, backendPort = 3000): Promise<ChildProcess> {
|
||||||
|
console.log('Starting frontend server...');
|
||||||
|
|
||||||
|
const frontend = spawn('npm', ['run', 'dev', '--', '--port', String(port)], {
|
||||||
|
cwd: CLIENT_DIR,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
frontend.stdout?.on('data', (data) => {
|
||||||
|
console.log(`[frontend] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
frontend.stderr?.on('data', (data) => {
|
||||||
|
console.error(`[frontend] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
processes.frontend = frontend;
|
||||||
|
|
||||||
|
await waitForServer(`https://localhost:${port}`);
|
||||||
|
console.log('Frontend server ready');
|
||||||
|
|
||||||
|
return frontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startServers(backendPort = 3000, frontendPort = 5173): Promise<ServerProcesses> {
|
||||||
|
await startBackend(backendPort);
|
||||||
|
await startFrontend(frontendPort, backendPort);
|
||||||
|
return processes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopServers(): void {
|
||||||
|
console.log('Stopping servers...');
|
||||||
|
|
||||||
|
if (processes.frontend) {
|
||||||
|
processes.frontend.kill('SIGTERM');
|
||||||
|
processes.frontend = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processes.backend) {
|
||||||
|
processes.backend.kill('SIGTERM');
|
||||||
|
processes.backend = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Servers stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProcesses(): ServerProcesses {
|
||||||
|
return processes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Basic E2E Tests', () => {
|
||||||
|
test('backend health check', async ({ request }) => {
|
||||||
|
const response = await request.get('http://localhost:3000/api/health');
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
expect(body.service).toBe('spiceflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frontend loads', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sessions list loads empty', async ({ request }) => {
|
||||||
|
const response = await request.get('http://localhost:3000/api/sessions');
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
const sessions = await response.json();
|
||||||
|
expect(Array.isArray(sessions)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Permissions Workflow', () => {
|
||||||
|
test('permission approval allows file creation and reading', async ({ page }) => {
|
||||||
|
// Increase timeout for this test since it involves real Claude interaction
|
||||||
|
test.setTimeout(180000);
|
||||||
|
|
||||||
|
// 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. Create a new session
|
||||||
|
const createButton = page.locator('button[title="New Session"]');
|
||||||
|
await expect(createButton).toBeVisible();
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
// 3. Wait for navigation to session page
|
||||||
|
await page.waitForURL(/\/session\/.+/);
|
||||||
|
console.log('[Test] Navigated to session page:', page.url());
|
||||||
|
|
||||||
|
// 4. Wait for the page to load
|
||||||
|
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('text=No messages yet')).toBeVisible();
|
||||||
|
|
||||||
|
// 5. Send message asking Claude to create foo.md with a haiku
|
||||||
|
const textarea = page.locator('textarea');
|
||||||
|
await expect(textarea).toBeVisible();
|
||||||
|
await textarea.fill(
|
||||||
|
'Create a file called foo.md containing a haiku about software testing. Title it "My Haiku" at the top. Just create the file, no other commentary needed.'
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendButton = page.locator('button[type="submit"]');
|
||||||
|
await expect(sendButton).toBeEnabled();
|
||||||
|
await sendButton.click();
|
||||||
|
|
||||||
|
// 6. Verify user message appears
|
||||||
|
await expect(
|
||||||
|
page.locator('text=Create a file called foo.md')
|
||||||
|
).toBeVisible();
|
||||||
|
console.log('[Test] User message displayed');
|
||||||
|
|
||||||
|
// 6b. Verify thinking indicator appears immediately
|
||||||
|
const assistantBubble = page.locator('.rounded-lg.border').filter({
|
||||||
|
has: page.locator('text=Assistant')
|
||||||
|
}).first();
|
||||||
|
await expect(assistantBubble).toBeVisible({ timeout: 2000 });
|
||||||
|
console.log('[Test] Thinking indicator appeared immediately');
|
||||||
|
|
||||||
|
// 7. Wait for permission request UI to appear
|
||||||
|
const permissionUI = page.locator('text=Claude needs permission');
|
||||||
|
await expect(permissionUI).toBeVisible({ timeout: 60000 });
|
||||||
|
console.log('[Test] Permission request UI appeared');
|
||||||
|
|
||||||
|
// 8. Verify the permission shows Write tool for foo.md
|
||||||
|
const permissionDescription = page.locator('li.font-mono').filter({
|
||||||
|
hasText: /Write.*foo\.md|create.*foo\.md/i
|
||||||
|
}).first();
|
||||||
|
await expect(permissionDescription).toBeVisible();
|
||||||
|
console.log('[Test] Permission shows foo.md file creation');
|
||||||
|
|
||||||
|
// 9. Click Accept button
|
||||||
|
const acceptButton = page.locator('button:has-text("Accept")');
|
||||||
|
await expect(acceptButton).toBeVisible();
|
||||||
|
await acceptButton.click();
|
||||||
|
console.log('[Test] Clicked Accept button');
|
||||||
|
|
||||||
|
// 10. Wait for permission UI to disappear
|
||||||
|
await expect(permissionUI).not.toBeVisible({ timeout: 10000 });
|
||||||
|
console.log('[Test] Permission UI disappeared');
|
||||||
|
|
||||||
|
// 11. Wait for streaming to complete after permission granted
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const bouncingDots = page.locator('.animate-bounce');
|
||||||
|
const pulsingCursor = page.locator('.animate-pulse');
|
||||||
|
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
||||||
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
||||||
|
console.log('[Test] First streaming complete');
|
||||||
|
|
||||||
|
// Count current assistant messages before sending new request
|
||||||
|
const assistantMessages = page.locator('.rounded-lg.border').filter({
|
||||||
|
has: page.locator('text=Assistant')
|
||||||
|
});
|
||||||
|
const messageCountBefore = await assistantMessages.count();
|
||||||
|
console.log('[Test] Assistant message count before read request:', messageCountBefore);
|
||||||
|
|
||||||
|
// 12. Now ask Claude to read the file back to verify it was created
|
||||||
|
await textarea.fill('Read the contents of foo.md and tell me what it says. Quote the file contents.');
|
||||||
|
await sendButton.click();
|
||||||
|
console.log('[Test] Asked Claude to read the file');
|
||||||
|
|
||||||
|
// 13. Wait for a NEW assistant message to appear
|
||||||
|
await expect(assistantMessages).toHaveCount(messageCountBefore + 1, { timeout: 60000 });
|
||||||
|
console.log('[Test] New assistant message appeared');
|
||||||
|
|
||||||
|
// Wait for streaming to complete on the new message
|
||||||
|
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
||||||
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
||||||
|
console.log('[Test] Second streaming complete');
|
||||||
|
|
||||||
|
// 14. Verify the response contains "My Haiku" - confirming file was created and read
|
||||||
|
const lastAssistantMessage = assistantMessages.last();
|
||||||
|
const responseText = await lastAssistantMessage.locator('.font-mono').textContent();
|
||||||
|
console.log('[Test] Claude read back:', responseText);
|
||||||
|
|
||||||
|
// The response should contain "My Haiku" which we asked Claude to title the file
|
||||||
|
expect(responseText).toBeTruthy();
|
||||||
|
expect(responseText).toContain('My Haiku');
|
||||||
|
console.log('[Test] Successfully verified "My Haiku" in Claude response');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('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 create a new session
|
||||||
|
const createButton = page.locator('button[title="New Session"]');
|
||||||
|
await expect(createButton).toBeVisible();
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
// 3. Wait for navigation to session page
|
||||||
|
await page.waitForURL(/\/session\/.+/);
|
||||||
|
console.log('[Test] Navigated to session page:', page.url());
|
||||||
|
|
||||||
|
// 4. Wait for the page to load (no loading spinner)
|
||||||
|
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 5. Verify we see the empty message state
|
||||||
|
await expect(page.locator('text=No messages yet')).toBeVisible();
|
||||||
|
|
||||||
|
// 6. Type a message in the textarea
|
||||||
|
const textarea = page.locator('textarea');
|
||||||
|
await expect(textarea).toBeVisible();
|
||||||
|
await textarea.fill('say hi. respond in a single concise sentence');
|
||||||
|
|
||||||
|
// 7. Click the send button
|
||||||
|
const sendButton = page.locator('button[type="submit"]');
|
||||||
|
await expect(sendButton).toBeEnabled();
|
||||||
|
await sendButton.click();
|
||||||
|
|
||||||
|
// 8. Verify user message appears immediately (optimistic update)
|
||||||
|
await expect(page.locator('text=say hi. respond in a single concise sentence')).toBeVisible();
|
||||||
|
console.log('[Test] User message displayed');
|
||||||
|
|
||||||
|
// 9. Verify thinking indicator appears immediately after sending
|
||||||
|
// The assistant bubble with bouncing dots should show right away (isThinking state)
|
||||||
|
const assistantMessage = page.locator('.rounded-lg.border').filter({
|
||||||
|
has: page.locator('text=Assistant')
|
||||||
|
}).last();
|
||||||
|
|
||||||
|
// Thinking indicator should appear almost immediately (within 2 seconds)
|
||||||
|
await expect(assistantMessage).toBeVisible({ timeout: 2000 });
|
||||||
|
console.log('[Test] Thinking indicator appeared immediately');
|
||||||
|
|
||||||
|
// Verify bouncing dots are present (thinking state)
|
||||||
|
const bouncingDotsInAssistant = assistantMessage.locator('.animate-bounce');
|
||||||
|
await expect(bouncingDotsInAssistant.first()).toBeVisible({ timeout: 2000 });
|
||||||
|
console.log('[Test] Bouncing dots visible in thinking state');
|
||||||
|
|
||||||
|
// 10. Wait for streaming to complete - progress indicator should disappear
|
||||||
|
// The streaming indicator has animate-bounce dots and animate-pulse cursor
|
||||||
|
// Note: With fast responses, the indicator may appear and disappear quickly,
|
||||||
|
// so we just verify it's gone after the response is visible
|
||||||
|
const bouncingDots = page.locator('.animate-bounce');
|
||||||
|
const pulsingCursor = page.locator('.animate-pulse');
|
||||||
|
|
||||||
|
// Wait for streaming indicators to disappear (they should be gone after message-stop)
|
||||||
|
await expect(bouncingDots).toHaveCount(0, { timeout: 30000 });
|
||||||
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 });
|
||||||
|
console.log('[Test] Streaming complete - progress indicator disappeared');
|
||||||
|
|
||||||
|
// 11. Verify the response contains some text content
|
||||||
|
const responseText = await assistantMessage.locator('.font-mono').textContent();
|
||||||
|
console.log('[Test] Assistant response text:', responseText);
|
||||||
|
expect(responseText).toBeTruthy();
|
||||||
|
expect(responseText!.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 12. Verify working directory indicator appears
|
||||||
|
// 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 /
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Development script - runs backend and frontend concurrently
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo -e "\n${YELLOW}Shutting down...${NC}"
|
||||||
|
kill $BACKEND_PID 2>/dev/null || true
|
||||||
|
kill $FRONTEND_PID 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup SIGINT SIGTERM
|
||||||
|
|
||||||
|
echo -e "${BLUE}=== Starting Spiceflow Development Environment ===${NC}\n"
|
||||||
|
|
||||||
|
# Start backend
|
||||||
|
echo -e "${GREEN}Starting backend server...${NC}"
|
||||||
|
cd "$ROOT_DIR/server"
|
||||||
|
clj -M:run &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
# Wait for backend to be ready
|
||||||
|
echo -e "${YELLOW}Waiting for backend...${NC}"
|
||||||
|
until curl -s http://localhost:3000/api/health > /dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo -e "${GREEN}Backend ready on http://localhost:3000${NC}"
|
||||||
|
|
||||||
|
# Start frontend
|
||||||
|
echo -e "${GREEN}Starting frontend server...${NC}"
|
||||||
|
cd "$ROOT_DIR/client"
|
||||||
|
npm run dev &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
# Wait for frontend to be ready
|
||||||
|
echo -e "${YELLOW}Waiting for frontend...${NC}"
|
||||||
|
until curl -sk https://localhost:5173 > /dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo -e "${GREEN}Frontend ready on https://0.0.0.0:5173${NC}"
|
||||||
|
|
||||||
|
# Get local IP for phone access
|
||||||
|
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
|
echo -e "\n${BLUE}=== Spiceflow Development Environment Ready ===${NC}"
|
||||||
|
echo -e "${GREEN}Backend:${NC} http://localhost:3000"
|
||||||
|
echo -e "${GREEN}Frontend:${NC} https://localhost:5173"
|
||||||
|
echo -e "${GREEN}Phone:${NC} https://${LOCAL_IP}:5173"
|
||||||
|
echo -e "\nPress Ctrl+C to stop\n"
|
||||||
|
|
||||||
|
# Wait for processes
|
||||||
|
wait
|
||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test script - runs backend, frontend, and e2e tests
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
BACKEND_PID=""
|
||||||
|
FRONTEND_PID=""
|
||||||
|
TEST_EXIT_CODE=0
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo -e "\n${YELLOW}Cleaning up...${NC}"
|
||||||
|
if [ -n "$FRONTEND_PID" ]; then
|
||||||
|
kill $FRONTEND_PID 2>/dev/null || true
|
||||||
|
wait $FRONTEND_PID 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -n "$BACKEND_PID" ]; then
|
||||||
|
kill $BACKEND_PID 2>/dev/null || true
|
||||||
|
wait $BACKEND_PID 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
# Remove test database
|
||||||
|
rm -f "$ROOT_DIR/server/test-e2e.db"
|
||||||
|
exit $TEST_EXIT_CODE
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup SIGINT SIGTERM EXIT
|
||||||
|
|
||||||
|
echo -e "${BLUE}=== Starting Spiceflow Test Environment ===${NC}\n"
|
||||||
|
|
||||||
|
# Clean up old test database
|
||||||
|
rm -f "$ROOT_DIR/server/test-e2e.db"
|
||||||
|
|
||||||
|
# Start backend with test database
|
||||||
|
echo -e "${GREEN}Starting backend server with test database...${NC}"
|
||||||
|
cd "$ROOT_DIR/server"
|
||||||
|
SPICEFLOW_DB="test-e2e.db" clj -M:run &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
# Wait for backend to be ready
|
||||||
|
echo -e "${YELLOW}Waiting for backend...${NC}"
|
||||||
|
until curl -s http://localhost:3000/api/health > /dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo -e "${GREEN}Backend ready on http://localhost:3000${NC}"
|
||||||
|
|
||||||
|
# Start frontend
|
||||||
|
echo -e "${GREEN}Starting frontend server...${NC}"
|
||||||
|
cd "$ROOT_DIR/client"
|
||||||
|
npm run dev -- --port 5173 &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
# Wait for frontend to be ready
|
||||||
|
echo -e "${YELLOW}Waiting for frontend...${NC}"
|
||||||
|
until curl -sk https://localhost:5173 > /dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo -e "${GREEN}Frontend ready on https://localhost:5173${NC}\n"
|
||||||
|
|
||||||
|
# Run e2e tests
|
||||||
|
echo -e "${BLUE}=== Running E2E Tests ===${NC}\n"
|
||||||
|
cd "$ROOT_DIR/e2e"
|
||||||
|
|
||||||
|
# Run playwright tests (skip global setup/teardown since we manage servers ourselves)
|
||||||
|
if SKIP_SERVER_SETUP=1 npx playwright test "$@"; then
|
||||||
|
echo -e "\n${GREEN}=== All tests passed ===${NC}"
|
||||||
|
TEST_EXIT_CODE=0
|
||||||
|
else
|
||||||
|
echo -e "\n${RED}=== Some tests failed ===${NC}"
|
||||||
|
TEST_EXIT_CODE=1
|
||||||
|
fi
|
||||||
+2
-3
@@ -2,8 +2,7 @@
|
|||||||
:deps {org.clojure/clojure {:mvn/version "1.11.1"}
|
:deps {org.clojure/clojure {:mvn/version "1.11.1"}
|
||||||
|
|
||||||
;; Web server
|
;; Web server
|
||||||
ring/ring-core {:mvn/version "1.10.0"}
|
ring/ring-core {:mvn/version "1.12.2"}
|
||||||
ring/ring-jetty-adapter {:mvn/version "1.10.0"}
|
|
||||||
ring/ring-json {:mvn/version "0.5.1"}
|
ring/ring-json {:mvn/version "0.5.1"}
|
||||||
ring-cors/ring-cors {:mvn/version "0.1.13"}
|
ring-cors/ring-cors {:mvn/version "0.1.13"}
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@
|
|||||||
metosin/reitit {:mvn/version "0.7.0-alpha7"}
|
metosin/reitit {:mvn/version "0.7.0-alpha7"}
|
||||||
|
|
||||||
;; WebSocket
|
;; WebSocket
|
||||||
info.sunng/ring-jetty9-adapter {:mvn/version "0.14.3"}
|
info.sunng/ring-jetty9-adapter {:mvn/version "0.33.4"}
|
||||||
|
|
||||||
;; Database
|
;; Database
|
||||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.894"}
|
com.github.seancorfield/next.jdbc {:mvn/version "1.3.894"}
|
||||||
|
|||||||
@@ -85,10 +85,25 @@
|
|||||||
:stop-reason (get-in data [:delta :stop_reason])}
|
:stop-reason (get-in data [:delta :stop_reason])}
|
||||||
"message_stop" {:event :message-stop}
|
"message_stop" {:event :message-stop}
|
||||||
;; Result contains final content and session_id
|
;; Result contains final content and session_id
|
||||||
"result" {:event :result
|
"result" (let [denials (:permission_denials data)]
|
||||||
:content (:result data)
|
(cond-> {:event :result
|
||||||
:session-id (:session_id data)
|
:content (:result data)
|
||||||
:cost (:total_cost_usd data)}
|
:session-id (:session_id data)
|
||||||
|
:cost (:total_cost_usd data)}
|
||||||
|
;; If there are permission denials, emit permission-request event
|
||||||
|
(seq denials)
|
||||||
|
(assoc :permission-request
|
||||||
|
{:tools (vec (distinct (map :tool_name denials)))
|
||||||
|
:denials (mapv (fn [{:keys [tool_name tool_input]}]
|
||||||
|
{:tool tool_name
|
||||||
|
:input tool_input
|
||||||
|
:description (case tool_name
|
||||||
|
"Bash" (:command tool_input)
|
||||||
|
"Write" (str "create " (:file_path tool_input))
|
||||||
|
"Edit" (str "edit " (:file_path tool_input))
|
||||||
|
"Read" (str "read " (:file_path tool_input))
|
||||||
|
(pr-str tool_input))})
|
||||||
|
denials)})))
|
||||||
;; Unknown type
|
;; Unknown type
|
||||||
{:raw data}))
|
{:raw data}))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
@@ -123,7 +138,7 @@
|
|||||||
[])))
|
[])))
|
||||||
|
|
||||||
(spawn-session [_ session-id opts]
|
(spawn-session [_ session-id opts]
|
||||||
(let [{:keys [working-dir model permission-mode]} opts
|
(let [{:keys [working-dir model permission-mode allowed-tools]} opts
|
||||||
;; Build base args - only include --resume if we have a session-id
|
;; Build base args - only include --resume if we have a session-id
|
||||||
;; --verbose is required when using --print with --output-format=stream-json
|
;; --verbose is required when using --print with --output-format=stream-json
|
||||||
args (cond-> ["claude"
|
args (cond-> ["claude"
|
||||||
@@ -133,10 +148,12 @@
|
|||||||
"--print"]
|
"--print"]
|
||||||
session-id (conj "--resume" session-id)
|
session-id (conj "--resume" session-id)
|
||||||
model (conj "--model" model)
|
model (conj "--model" model)
|
||||||
permission-mode (conj "--permission-mode" permission-mode))
|
permission-mode (conj "--permission-mode" permission-mode)
|
||||||
|
;; Add allowed tools for permission grants
|
||||||
|
(seq allowed-tools) (into (cons "--allowedTools" allowed-tools)))
|
||||||
pb (ProcessBuilder. args)]
|
pb (ProcessBuilder. args)]
|
||||||
(when working-dir
|
;; Default to home directory if no working-dir specified
|
||||||
(.directory pb (io/file working-dir)))
|
(.directory pb (io/file (or working-dir (System/getProperty "user.home"))))
|
||||||
(.redirectErrorStream pb false)
|
(.redirectErrorStream pb false)
|
||||||
(let [process (.start pb)]
|
(let [process (.start pb)]
|
||||||
{:process process
|
{:process process
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
[ring.util.response :as response]
|
[ring.util.response :as response]
|
||||||
[spiceflow.db.protocol :as db]
|
[spiceflow.db.protocol :as db]
|
||||||
[spiceflow.session.manager :as manager]
|
[spiceflow.session.manager :as manager]
|
||||||
[spiceflow.adapters.claude :as claude]
|
|
||||||
[clojure.tools.logging :as log]))
|
[clojure.tools.logging :as log]))
|
||||||
|
|
||||||
(defn- json-response
|
(defn- json-response
|
||||||
@@ -59,6 +58,16 @@
|
|||||||
(response/status (response/response nil) 204))
|
(response/status (response/response nil) 204))
|
||||||
(error-response 404 "Session not found")))))
|
(error-response 404 "Session not found")))))
|
||||||
|
|
||||||
|
(defn update-session-handler
|
||||||
|
[store]
|
||||||
|
(fn [request]
|
||||||
|
(let [id (get-in request [:path-params :id])
|
||||||
|
body (:body request)]
|
||||||
|
(if (db/get-session store id)
|
||||||
|
(let [updated (db/update-session store id (select-keys body [:title]))]
|
||||||
|
(json-response updated))
|
||||||
|
(error-response 404 "Session not found")))))
|
||||||
|
|
||||||
(defn send-message-handler
|
(defn send-message-handler
|
||||||
[store broadcast-fn]
|
[store broadcast-fn]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
@@ -73,41 +82,46 @@
|
|||||||
(manager/stream-session-response store id
|
(manager/stream-session-response store id
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(broadcast-fn id event)))
|
(broadcast-fn id event)))
|
||||||
|
;; Always send message-stop when stream ends to ensure client clears progress indicator
|
||||||
|
(broadcast-fn id {:event :message-stop})
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/error "Streaming error:" (.getMessage e))
|
(log/error "Streaming error:" (.getMessage e))
|
||||||
(broadcast-fn id {:event :error :message (.getMessage e)}))))
|
(broadcast-fn id {:event :error :message (.getMessage e)})
|
||||||
|
;; Also send message-stop on error to clear progress indicator
|
||||||
|
(broadcast-fn id {:event :message-stop}))))
|
||||||
(json-response {:status "sent"})
|
(json-response {:status "sent"})
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(error-response 500 (.getMessage e))))
|
(error-response 500 (.getMessage e))))
|
||||||
(error-response 404 "Session not found")))))
|
(error-response 404 "Session not found")))))
|
||||||
|
|
||||||
;; Discovery handlers
|
(defn permission-response-handler
|
||||||
(defn discover-claude-handler
|
"Handle permission response: accept, deny, or steer"
|
||||||
[_store]
|
[store broadcast-fn]
|
||||||
(fn [_request]
|
|
||||||
(let [adapter (claude/create-adapter)
|
|
||||||
sessions (manager/discover-all-sessions)]
|
|
||||||
(json-response (->> sessions
|
|
||||||
(filter #(= :claude (:provider %)))
|
|
||||||
vec)))))
|
|
||||||
|
|
||||||
(defn discover-opencode-handler
|
|
||||||
[_store]
|
|
||||||
(fn [_request]
|
|
||||||
(let [sessions (manager/discover-all-sessions)]
|
|
||||||
(json-response (->> sessions
|
|
||||||
(filter #(= :opencode (:provider %)))
|
|
||||||
vec)))))
|
|
||||||
|
|
||||||
(defn import-session-handler
|
|
||||||
[store]
|
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [body (:body request)]
|
(let [id (get-in request [:path-params :id])
|
||||||
(if (:external-id body)
|
{:keys [response message]} (:body request)
|
||||||
(let [session (manager/import-session store body)]
|
response-type (keyword response)]
|
||||||
(-> (json-response session)
|
(if-let [session (db/get-session store id)]
|
||||||
(response/status 201)))
|
(if (#{:accept :deny :steer} response-type)
|
||||||
(error-response 400 "Missing external-id")))))
|
(try
|
||||||
|
;; Respond to permission and get new process handle
|
||||||
|
(manager/respond-to-permission store id response-type message)
|
||||||
|
;; Stream the response in background
|
||||||
|
(future
|
||||||
|
(try
|
||||||
|
(manager/stream-session-response store id
|
||||||
|
(fn [event]
|
||||||
|
(broadcast-fn id event)))
|
||||||
|
(broadcast-fn id {:event :message-stop})
|
||||||
|
(catch Exception e
|
||||||
|
(log/error "Streaming error after permission response:" (.getMessage e))
|
||||||
|
(broadcast-fn id {:event :error :message (.getMessage e)})
|
||||||
|
(broadcast-fn id {:event :message-stop}))))
|
||||||
|
(json-response {:status "permission-response-sent"})
|
||||||
|
(catch Exception e
|
||||||
|
(error-response 400 (.getMessage e))))
|
||||||
|
(error-response 400 "Invalid response type. Must be: accept, deny, or steer"))
|
||||||
|
(error-response 404 "Session not found")))))
|
||||||
|
|
||||||
;; Health check
|
;; Health check
|
||||||
(defn health-handler
|
(defn health-handler
|
||||||
@@ -122,11 +136,10 @@
|
|||||||
["/sessions" {:get (list-sessions-handler store)
|
["/sessions" {:get (list-sessions-handler store)
|
||||||
:post (create-session-handler store)}]
|
:post (create-session-handler store)}]
|
||||||
["/sessions/:id" {:get (get-session-handler store)
|
["/sessions/:id" {:get (get-session-handler store)
|
||||||
|
:patch (update-session-handler store)
|
||||||
:delete (delete-session-handler store)}]
|
:delete (delete-session-handler store)}]
|
||||||
["/sessions/:id/send" {:post (send-message-handler store broadcast-fn)}]
|
["/sessions/:id/send" {:post (send-message-handler store broadcast-fn)}]
|
||||||
["/discover/claude" {:get (discover-claude-handler store)}]
|
["/sessions/:id/permission" {:post (permission-response-handler store broadcast-fn)}]]])
|
||||||
["/discover/opencode" {:get (discover-opencode-handler store)}]
|
|
||||||
["/import" {:post (import-session-handler store)}]]])
|
|
||||||
|
|
||||||
(defn create-app
|
(defn create-app
|
||||||
"Create the Ring application"
|
"Create the Ring application"
|
||||||
|
|||||||
@@ -1,111 +1,101 @@
|
|||||||
(ns spiceflow.api.websocket
|
(ns spiceflow.api.websocket
|
||||||
"WebSocket handlers for real-time updates"
|
"WebSocket handlers for real-time updates"
|
||||||
(:require [jsonista.core :as json]
|
(:require [jsonista.core :as json]
|
||||||
|
[ring.websocket :as ring-ws]
|
||||||
[clojure.tools.logging :as log])
|
[clojure.tools.logging :as log])
|
||||||
(:import [org.eclipse.jetty.websocket.api Session WebSocketListener]
|
(:import [java.util.concurrent ConcurrentHashMap]))
|
||||||
[java.util.concurrent ConcurrentHashMap]))
|
|
||||||
|
|
||||||
(def ^:private mapper (json/object-mapper {:encode-key-fn name}))
|
(def ^:private mapper (json/object-mapper {:encode-key-fn name
|
||||||
|
:decode-key-fn keyword}))
|
||||||
|
|
||||||
;; Connected WebSocket sessions: session-id -> #{ws-sessions}
|
;; Connected WebSocket sessions: session-id -> #{sockets}
|
||||||
(defonce ^:private connections (ConcurrentHashMap.))
|
(defonce ^:private connections (ConcurrentHashMap.))
|
||||||
|
|
||||||
;; All connected WebSocket sessions for broadcast
|
;; All connected WebSocket sockets for broadcast
|
||||||
(defonce ^:private all-connections (ConcurrentHashMap/newKeySet))
|
(defonce ^:private all-connections (ConcurrentHashMap/newKeySet))
|
||||||
|
|
||||||
(defn- send-to-ws
|
(defn- send-to-ws
|
||||||
"Send a message to a WebSocket session"
|
"Send a message to a WebSocket socket"
|
||||||
[^Session ws-session message]
|
[socket message]
|
||||||
(try
|
(try
|
||||||
(when (.isOpen ws-session)
|
(let [json-str (json/write-value-as-string message mapper)]
|
||||||
(let [json-str (json/write-value-as-string message mapper)]
|
(log/debug "Sending WS message:" json-str)
|
||||||
(.sendString (.getRemote ws-session) json-str)))
|
(ring-ws/send socket json-str))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/debug "Failed to send to WebSocket:" (.getMessage e)))))
|
(log/error "Failed to send to WebSocket:" (.getMessage e)))))
|
||||||
|
|
||||||
(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)
|
||||||
(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 [ws-session subscribers]
|
(doseq [socket subscribers]
|
||||||
(send-to-ws ws-session message)))))
|
(send-to-ws socket message)))))
|
||||||
|
|
||||||
(defn broadcast-all
|
(defn broadcast-all
|
||||||
"Broadcast an event to all connected WebSocket sessions"
|
"Broadcast an event to all connected WebSocket sockets"
|
||||||
[event]
|
[event]
|
||||||
(doseq [ws-session all-connections]
|
(doseq [socket all-connections]
|
||||||
(send-to-ws ws-session event)))
|
(send-to-ws socket event)))
|
||||||
|
|
||||||
(defn- subscribe-to-session
|
(defn- subscribe-to-session
|
||||||
"Subscribe a WebSocket session to updates for a session"
|
"Subscribe a WebSocket socket to updates for a session"
|
||||||
[ws-session session-id]
|
[socket session-id]
|
||||||
(.compute connections session-id
|
(log/debug "Subscribing socket to session:" session-id)
|
||||||
(fn [_k existing]
|
(let [subscribers (or (.get connections session-id)
|
||||||
(let [subscribers (or existing (ConcurrentHashMap/newKeySet))]
|
(let [new-set (ConcurrentHashMap/newKeySet)]
|
||||||
(.add subscribers ws-session)
|
(.putIfAbsent connections session-id new-set)
|
||||||
subscribers))))
|
(or (.get connections session-id) new-set)))]
|
||||||
|
(.add subscribers socket)))
|
||||||
|
|
||||||
(defn- unsubscribe-from-session
|
(defn- unsubscribe-from-session
|
||||||
"Unsubscribe a WebSocket session from a session"
|
"Unsubscribe a WebSocket socket from a session"
|
||||||
[ws-session session-id]
|
[socket session-id]
|
||||||
(when-let [subscribers (.get connections session-id)]
|
(when-let [subscribers (.get connections session-id)]
|
||||||
(.remove subscribers ws-session)
|
(.remove subscribers socket)
|
||||||
(when (.isEmpty subscribers)
|
(when (.isEmpty subscribers)
|
||||||
(.remove connections session-id))))
|
(.remove connections session-id))))
|
||||||
|
|
||||||
(defn- unsubscribe-from-all
|
(defn- unsubscribe-from-all
|
||||||
"Unsubscribe a WebSocket session from all sessions"
|
"Unsubscribe a WebSocket socket from all sessions"
|
||||||
[ws-session]
|
[socket]
|
||||||
(doseq [[session-id _] connections]
|
(doseq [[session-id _] connections]
|
||||||
(unsubscribe-from-session ws-session session-id)))
|
(unsubscribe-from-session socket session-id)))
|
||||||
|
|
||||||
(defn- handle-message
|
(defn- handle-message
|
||||||
"Handle an incoming WebSocket message"
|
"Handle an incoming WebSocket message"
|
||||||
[ws-session message]
|
[socket message]
|
||||||
(try
|
(try
|
||||||
|
(log/debug "Received WS message:" message)
|
||||||
(let [data (json/read-value message mapper)]
|
(let [data (json/read-value message mapper)]
|
||||||
(case (:type data)
|
(case (:type data)
|
||||||
"subscribe" (do
|
"subscribe" (do
|
||||||
(subscribe-to-session ws-session (:session-id data))
|
(subscribe-to-session socket (:session-id data))
|
||||||
(send-to-ws ws-session {:type "subscribed"
|
(send-to-ws socket {:type "subscribed"
|
||||||
:session-id (:session-id data)}))
|
:session-id (:session-id data)}))
|
||||||
"unsubscribe" (do
|
"unsubscribe" (do
|
||||||
(unsubscribe-from-session ws-session (:session-id data))
|
(unsubscribe-from-session socket (:session-id data))
|
||||||
(send-to-ws ws-session {:type "unsubscribed"
|
(send-to-ws socket {:type "unsubscribed"
|
||||||
:session-id (:session-id data)}))
|
:session-id (:session-id data)}))
|
||||||
"ping" (send-to-ws ws-session {:type "pong"})
|
"ping" (send-to-ws socket {:type "pong"})
|
||||||
(log/debug "Unknown WebSocket message type:" (:type data))))
|
(log/debug "Unknown WebSocket message type:" (:type data))))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/warn "Failed to handle WebSocket message:" (.getMessage e)))))
|
(log/warn "Failed to handle WebSocket message:" (.getMessage e)))))
|
||||||
|
|
||||||
(defn create-ws-listener
|
|
||||||
"Create a WebSocket listener"
|
|
||||||
[]
|
|
||||||
(let [session-atom (atom nil)]
|
|
||||||
(reify WebSocketListener
|
|
||||||
(onWebSocketConnect [_ session]
|
|
||||||
(reset! session-atom session)
|
|
||||||
(log/debug "WebSocket connected")
|
|
||||||
(.add all-connections session)
|
|
||||||
(send-to-ws session {:type "connected"}))
|
|
||||||
|
|
||||||
(onWebSocketText [_ message]
|
|
||||||
(handle-message @session-atom message))
|
|
||||||
|
|
||||||
(onWebSocketBinary [_ _payload _offset _len]
|
|
||||||
(log/debug "Binary WebSocket message ignored"))
|
|
||||||
|
|
||||||
(onWebSocketClose [_ status-code reason]
|
|
||||||
(log/debug "WebSocket closed:" status-code reason)
|
|
||||||
(when-let [session @session-atom]
|
|
||||||
(.remove all-connections session)
|
|
||||||
(unsubscribe-from-all session)))
|
|
||||||
|
|
||||||
(onWebSocketError [_ cause]
|
|
||||||
(log/warn "WebSocket error:" (.getMessage cause))))))
|
|
||||||
|
|
||||||
(defn ws-handler
|
(defn ws-handler
|
||||||
"Ring handler for WebSocket upgrade"
|
"Ring handler for WebSocket upgrade - returns websocket listener map"
|
||||||
[_request]
|
[_request]
|
||||||
{:ring.websocket/listener (create-ws-listener)})
|
{:ring.websocket/listener
|
||||||
|
{:on-open (fn [socket]
|
||||||
|
(log/debug "WebSocket connected")
|
||||||
|
(.add all-connections socket)
|
||||||
|
(send-to-ws socket {:type "connected"}))
|
||||||
|
:on-message (fn [socket message]
|
||||||
|
(handle-message socket message))
|
||||||
|
:on-close (fn [socket _status-code _reason]
|
||||||
|
(log/debug "WebSocket closed")
|
||||||
|
(.remove all-connections socket)
|
||||||
|
(unsubscribe-from-all socket))
|
||||||
|
:on-error (fn [_socket error]
|
||||||
|
(log/warn "WebSocket error:" (.getMessage error)))}})
|
||||||
|
|||||||
@@ -18,21 +18,34 @@
|
|||||||
(sqlite/create-store db-path)))
|
(sqlite/create-store db-path)))
|
||||||
:stop nil)
|
:stop nil)
|
||||||
|
|
||||||
|
;; Atom to hold the Jetty server instance
|
||||||
|
(defonce ^:private jetty-server (atom nil))
|
||||||
|
|
||||||
;; HTTP Server
|
;; HTTP Server
|
||||||
(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")
|
||||||
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
|
||||||
|
app (fn [request]
|
||||||
|
(if (and (jetty/ws-upgrade-request? request)
|
||||||
|
(= "/api/ws" (:uri request)))
|
||||||
|
(ws/ws-handler request)
|
||||||
|
(api-app request)))
|
||||||
|
srv (jetty/run-jetty app
|
||||||
|
{:port port
|
||||||
|
:host host
|
||||||
|
:join? false
|
||||||
|
:allow-null-path-info true})]
|
||||||
(log/info "Starting Spiceflow server on" (str host ":" port))
|
(log/info "Starting Spiceflow server on" (str host ":" port))
|
||||||
(jetty/run-jetty app
|
(reset! jetty-server srv)
|
||||||
{:port port
|
srv)
|
||||||
:host host
|
|
||||||
:join? false
|
|
||||||
:websockets {"/api/ws" ws/ws-handler}}))
|
|
||||||
:stop (do
|
:stop (do
|
||||||
(log/info "Stopping Spiceflow server...")
|
(log/info "Stopping Spiceflow server...")
|
||||||
(manager/cleanup-all store)
|
(manager/cleanup-all store)
|
||||||
(.stop server)))
|
(when-let [srv @jetty-server]
|
||||||
|
(.stop srv)
|
||||||
|
(reset! jetty-server nil))))
|
||||||
|
|
||||||
(defn -main
|
(defn -main
|
||||||
"Main entry point"
|
"Main entry point"
|
||||||
@@ -50,7 +63,4 @@
|
|||||||
|
|
||||||
;; Test database
|
;; Test database
|
||||||
(require '[spiceflow.db.protocol :as db])
|
(require '[spiceflow.db.protocol :as db])
|
||||||
(db/get-sessions store)
|
(db/get-sessions store))
|
||||||
|
|
||||||
;; Test discovery
|
|
||||||
(manager/discover-all-sessions))
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
;; 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]
|
||||||
@@ -18,18 +21,6 @@
|
|||||||
:opencode (opencode/create-adapter)
|
:opencode (opencode/create-adapter)
|
||||||
(throw (ex-info "Unknown provider" {:provider provider}))))
|
(throw (ex-info "Unknown provider" {:provider provider}))))
|
||||||
|
|
||||||
(defn discover-all-sessions
|
|
||||||
"Discover sessions from all configured providers"
|
|
||||||
[]
|
|
||||||
(let [claude-sessions (adapter/discover-sessions (claude/create-adapter))
|
|
||||||
opencode-sessions (adapter/discover-sessions (opencode/create-adapter))]
|
|
||||||
(concat claude-sessions opencode-sessions)))
|
|
||||||
|
|
||||||
(defn import-session
|
|
||||||
"Import a discovered session into the database"
|
|
||||||
[store session]
|
|
||||||
(db/save-session store session))
|
|
||||||
|
|
||||||
(defn get-active-process
|
(defn get-active-process
|
||||||
"Get the active process handle for a session"
|
"Get the active process handle for a session"
|
||||||
[session-id]
|
[session-id]
|
||||||
@@ -83,6 +74,23 @@
|
|||||||
;; Send to CLI
|
;; Send to CLI
|
||||||
(adapter/send-message adapter handle message)))
|
(adapter/send-message adapter handle message)))
|
||||||
|
|
||||||
|
;; Permission handling - defined before stream-session-response which uses them
|
||||||
|
|
||||||
|
(defn set-pending-permission
|
||||||
|
"Store a pending permission request for a session"
|
||||||
|
[session-id permission-request]
|
||||||
|
(.put pending-permissions session-id permission-request))
|
||||||
|
|
||||||
|
(defn get-pending-permission
|
||||||
|
"Get pending permission request for a session"
|
||||||
|
[session-id]
|
||||||
|
(.get pending-permissions session-id))
|
||||||
|
|
||||||
|
(defn clear-pending-permission
|
||||||
|
"Clear pending permission for a session"
|
||||||
|
[session-id]
|
||||||
|
(.remove pending-permissions session-id))
|
||||||
|
|
||||||
(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]
|
||||||
@@ -101,21 +109,44 @@
|
|||||||
;; Accumulate text content
|
;; Accumulate text content
|
||||||
(when-let [text (:text event)]
|
(when-let [text (:text event)]
|
||||||
(.append content-buffer text))
|
(.append content-buffer text))
|
||||||
;; Capture external session-id from init or result event (for new sessions)
|
;; Capture external session-id and cwd from init event (for new sessions)
|
||||||
(when (and (contains? #{:init :result} (:event event))
|
(when (= :init (:event event))
|
||||||
|
(when (and (:session-id event)
|
||||||
|
(not (:external-id session)))
|
||||||
|
(log/debug "Capturing external session-id:" (:session-id event))
|
||||||
|
(db/update-session store session-id {:external-id (:session-id event)}))
|
||||||
|
;; Also capture working directory from cwd
|
||||||
|
(when (and (:cwd event)
|
||||||
|
(or (nil? (:working-dir session))
|
||||||
|
(empty? (:working-dir session))))
|
||||||
|
(log/debug "Capturing working directory:" (:cwd event))
|
||||||
|
(db/update-session store session-id {:working-dir (:cwd event)})))
|
||||||
|
;; Also capture external-id from result event if not already set
|
||||||
|
(when (and (= :result (:event event))
|
||||||
(:session-id event)
|
(:session-id event)
|
||||||
(not (:external-id session)))
|
(not (:external-id session)))
|
||||||
(log/debug "Capturing external session-id:" (:session-id event))
|
(log/debug "Capturing external session-id from result:" (: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)}))
|
||||||
;; On result event, save the accumulated message
|
;; On result event, check for permission requests
|
||||||
(when (= :result (:event event))
|
(when (= :result (:event event))
|
||||||
(let [content (.toString content-buffer)]
|
(let [content (.toString content-buffer)]
|
||||||
|
;; Save accumulated message if any
|
||||||
(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
|
||||||
|
(when-let [perm-req (:permission-request event)]
|
||||||
|
(log/info "Permission request detected:" perm-req)
|
||||||
|
(set-pending-permission session-id perm-req)
|
||||||
|
(callback {:event :permission-request
|
||||||
|
:permission-request perm-req}))))))
|
||||||
;; Update session status when stream ends
|
;; Update session status when stream ends
|
||||||
(db/update-session store session-id {:status :idle})
|
;; If there's a pending permission, set status to awaiting-permission
|
||||||
|
(let [new-status (if (get-pending-permission session-id)
|
||||||
|
:awaiting-permission
|
||||||
|
:idle)]
|
||||||
|
(db/update-session store session-id {:status new-status}))
|
||||||
(.remove active-processes session-id)))
|
(.remove active-processes session-id)))
|
||||||
|
|
||||||
(defn cleanup-all
|
(defn cleanup-all
|
||||||
@@ -126,3 +157,38 @@
|
|||||||
(stop-session store session-id)
|
(stop-session store session-id)
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/warn "Failed to stop session:" session-id (.getMessage e))))))
|
(log/warn "Failed to stop session:" session-id (.getMessage e))))))
|
||||||
|
|
||||||
|
(defn respond-to-permission
|
||||||
|
"Respond to a permission request.
|
||||||
|
response-type: :accept, :deny, or :steer
|
||||||
|
message: optional message for :deny or :steer responses"
|
||||||
|
[store session-id response-type message]
|
||||||
|
(let [session (db/get-session store session-id)
|
||||||
|
_ (when-not session
|
||||||
|
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||||
|
pending (get-pending-permission session-id)
|
||||||
|
_ (when-not pending
|
||||||
|
(throw (ex-info "No pending permission request" {:session-id session-id})))
|
||||||
|
adapter (get-adapter (:provider session))
|
||||||
|
;; Build spawn options based on response type
|
||||||
|
opts (cond-> {:working-dir (:working-dir session)}
|
||||||
|
;; For :accept, grant the requested tools
|
||||||
|
(= response-type :accept)
|
||||||
|
(assoc :allowed-tools (:tools pending)))
|
||||||
|
;; Determine the message to send
|
||||||
|
send-msg (case response-type
|
||||||
|
:accept "continue"
|
||||||
|
:deny "Permission denied. Find another approach without using that tool."
|
||||||
|
:steer message)
|
||||||
|
;; Spawn new process with --resume and possibly --allowedTools
|
||||||
|
handle (adapter/spawn-session adapter
|
||||||
|
(:external-id session)
|
||||||
|
opts)]
|
||||||
|
;; Clear pending permission
|
||||||
|
(clear-pending-permission session-id)
|
||||||
|
;; Store new process handle
|
||||||
|
(.put active-processes session-id handle)
|
||||||
|
(db/update-session store session-id {:status :running})
|
||||||
|
;; Send the response message
|
||||||
|
(adapter/send-message adapter handle send-msg)
|
||||||
|
handle))
|
||||||
|
|||||||
Reference in New Issue
Block a user