add git diffs and permission support
This commit is contained in:
@@ -71,7 +71,13 @@ Claude Code/OpenCode CLI ↔ Spiceflow Server (Clojure) ↔ PWA Client (SvelteKi
|
|||||||
- **State**: Svelte stores in `src/lib/stores/` (sessions, runtime selection)
|
- **State**: Svelte stores in `src/lib/stores/` (sessions, runtime selection)
|
||||||
- **API client**: `src/lib/api.ts` - HTTP and WebSocket clients
|
- **API client**: `src/lib/api.ts` - HTTP and WebSocket clients
|
||||||
- **Components**: `src/lib/components/` - UI components
|
- **Components**: `src/lib/components/` - UI components
|
||||||
|
- `MessageList.svelte` - Displays messages with collapsible long content
|
||||||
|
- `PermissionRequest.svelte` - Permission prompts with accept/deny/steer actions
|
||||||
|
- `FileDiff.svelte` - Expandable file diffs for Write/Edit operations
|
||||||
|
- `SessionSettings.svelte` - Session settings dropdown (auto-accept edits)
|
||||||
|
- `InputBar.svelte` - Message input with steer mode support
|
||||||
- **PWA**: vite-plugin-pwa with Workbox service worker
|
- **PWA**: vite-plugin-pwa with Workbox service worker
|
||||||
|
- **Responsive**: Landscape mobile mode collapses header to hamburger menu
|
||||||
|
|
||||||
### Key Protocols
|
### Key Protocols
|
||||||
|
|
||||||
@@ -98,6 +104,29 @@ Server configuration via `server/resources/config.edn` or environment variables:
|
|||||||
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory |
|
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory |
|
||||||
| `OPENCODE_CMD` | opencode | OpenCode command |
|
| `OPENCODE_CMD` | opencode | OpenCode command |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Permission Handling
|
||||||
|
When Claude Code requests permission for file operations (Write/Edit) or shell commands (Bash), Spiceflow intercepts these and presents them to the user:
|
||||||
|
- **Accept**: Grant permission and continue
|
||||||
|
- **Deny**: Reject the request
|
||||||
|
- **Steer ("No, and...")**: Redirect Claude with alternative instructions
|
||||||
|
|
||||||
|
File operations show expandable diffs displaying the exact changes being made.
|
||||||
|
|
||||||
|
### Auto-Accept Edits
|
||||||
|
Claude sessions can enable "Auto-accept edits" in session settings to automatically grant Write/Edit permissions, reducing interruptions during coding sessions.
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- **Rename**: Click session title to rename
|
||||||
|
- **Delete**: Remove sessions from the session list
|
||||||
|
- **Condense**: Collapse long messages for easier scrolling
|
||||||
|
|
||||||
|
### Mobile Optimization
|
||||||
|
- Landscape mode collapses the header to a hamburger menu
|
||||||
|
- Compact file diffs with minimal padding
|
||||||
|
- Touch-friendly permission buttons
|
||||||
|
|
||||||
## Session Flow
|
## Session Flow
|
||||||
|
|
||||||
1. User opens PWA → sees list of tracked sessions
|
1. User opens PWA → sees list of tracked sessions
|
||||||
@@ -107,7 +136,22 @@ Server configuration via `server/resources/config.edn` or environment variables:
|
|||||||
5. Server pipes user message to stdin
|
5. Server pipes user message to stdin
|
||||||
6. CLI streams response via stdout (JSONL format)
|
6. CLI streams response via stdout (JSONL format)
|
||||||
7. Server broadcasts to client via WebSocket
|
7. Server broadcasts to client via WebSocket
|
||||||
8. Process completes → response saved to database
|
8. **If permission required** → WebSocket sends permission request → User accepts/denies/steers
|
||||||
|
9. Process completes → response saved to database
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/health` | GET | Health check |
|
||||||
|
| `/api/sessions` | GET | List all sessions |
|
||||||
|
| `/api/sessions` | POST | Create new session |
|
||||||
|
| `/api/sessions/:id` | GET | Get session with messages |
|
||||||
|
| `/api/sessions/:id` | PATCH | Update session (title, auto-accept-edits) |
|
||||||
|
| `/api/sessions/:id` | DELETE | Delete session |
|
||||||
|
| `/api/sessions/:id/send` | POST | Send message to session |
|
||||||
|
| `/api/sessions/:id/permission` | POST | Respond to permission request |
|
||||||
|
| `/ws` | WebSocket | Real-time message streaming |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
|
|||||||
Generated
+15
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "spiceflow-client",
|
"name": "spiceflow-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"marked": "^17.0.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.1",
|
"@sveltejs/adapter-static": "^3.0.1",
|
||||||
"@sveltejs/kit": "^2.5.0",
|
"@sveltejs/kit": "^2.5.0",
|
||||||
@@ -5005,6 +5008,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "17.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
|
||||||
|
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -26,5 +26,8 @@
|
|||||||
"vite": "^5.0.12",
|
"vite": "^5.0.12",
|
||||||
"vite-plugin-pwa": "^0.19.2",
|
"vite-plugin-pwa": "^0.19.2",
|
||||||
"workbox-window": "^7.0.0"
|
"workbox-window": "^7.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"marked": "^17.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,4 +61,18 @@
|
|||||||
.safe-top {
|
.safe-top {
|
||||||
padding-top: env(safe-area-inset-top, 0);
|
padding-top: env(safe-area-inset-top, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Landscape mobile - short viewport height indicates landscape on mobile */
|
||||||
|
.landscape-menu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 450px) {
|
||||||
|
.landscape-mobile\:hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.landscape-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-2
@@ -8,12 +8,16 @@ export interface Session {
|
|||||||
title?: string;
|
title?: string;
|
||||||
'working-dir'?: string;
|
'working-dir'?: string;
|
||||||
workingDir?: string;
|
workingDir?: string;
|
||||||
status: 'idle' | 'running' | 'completed';
|
status: 'idle' | 'processing' | 'awaiting-permission';
|
||||||
|
'auto-accept-edits'?: boolean;
|
||||||
|
autoAcceptEdits?: boolean;
|
||||||
'created-at'?: string;
|
'created-at'?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
'updated-at'?: string;
|
'updated-at'?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
|
'pending-permission'?: PermissionRequest;
|
||||||
|
pendingPermission?: PermissionRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -27,9 +31,26 @@ export interface Message {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WriteToolInput {
|
||||||
|
file_path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditToolInput {
|
||||||
|
file_path: string;
|
||||||
|
old_string: string;
|
||||||
|
new_string: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BashToolInput {
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolInput = WriteToolInput | EditToolInput | BashToolInput | Record<string, unknown>;
|
||||||
|
|
||||||
export interface PermissionDenial {
|
export interface PermissionDenial {
|
||||||
tool: string;
|
tool: string;
|
||||||
input: Record<string, unknown>;
|
input: ToolInput;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +68,7 @@ export interface StreamEvent {
|
|||||||
type?: string;
|
type?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
'working-dir'?: string;
|
||||||
'permission-request'?: PermissionRequest;
|
'permission-request'?: PermissionRequest;
|
||||||
permissionRequest?: PermissionRequest;
|
permissionRequest?: PermissionRequest;
|
||||||
}
|
}
|
||||||
@@ -141,6 +163,10 @@ export class WebSocketClient {
|
|||||||
private reconnectDelay = 1000;
|
private reconnectDelay = 1000;
|
||||||
private listeners: Map<string, Set<(event: StreamEvent) => void>> = new Map();
|
private listeners: Map<string, Set<(event: StreamEvent) => void>> = new Map();
|
||||||
private globalListeners: Set<(event: StreamEvent) => void> = new Set();
|
private globalListeners: Set<(event: StreamEvent) => void> = new Set();
|
||||||
|
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private heartbeatTimeoutMs = 25000; // Send ping every 25 seconds
|
||||||
|
private lastPongTime: number = 0;
|
||||||
|
private pongTimeoutMs = 10000; // Consider connection dead if no pong within 10 seconds
|
||||||
|
|
||||||
constructor(url: string = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/ws`) {
|
constructor(url: string = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/ws`) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
@@ -158,11 +184,13 @@ export class WebSocketClient {
|
|||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
|
this.startHeartbeat();
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
console.log('WebSocket disconnected');
|
console.log('WebSocket disconnected');
|
||||||
|
this.stopHeartbeat();
|
||||||
this.attemptReconnect();
|
this.attemptReconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,6 +202,7 @@ export class WebSocketClient {
|
|||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data) as StreamEvent;
|
const data = JSON.parse(event.data) as StreamEvent;
|
||||||
|
console.log('[WS Raw] Message received:', data.event || data.type, data);
|
||||||
this.handleMessage(data);
|
this.handleMessage(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse WebSocket message:', e);
|
console.error('Failed to parse WebSocket message:', e);
|
||||||
@@ -183,6 +212,12 @@ export class WebSocketClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMessage(event: StreamEvent) {
|
private handleMessage(event: StreamEvent) {
|
||||||
|
// Track pong responses for heartbeat
|
||||||
|
if (event.type === 'pong') {
|
||||||
|
this.lastPongTime = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Notify global listeners
|
// Notify global listeners
|
||||||
this.globalListeners.forEach((listener) => listener(event));
|
this.globalListeners.forEach((listener) => listener(event));
|
||||||
|
|
||||||
@@ -194,6 +229,32 @@ export class WebSocketClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startHeartbeat() {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.lastPongTime = Date.now();
|
||||||
|
|
||||||
|
this.heartbeatInterval = setInterval(() => {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
// Check if we received a pong since last ping
|
||||||
|
const timeSinceLastPong = Date.now() - this.lastPongTime;
|
||||||
|
if (timeSinceLastPong > this.heartbeatTimeoutMs + this.pongTimeoutMs) {
|
||||||
|
console.warn('WebSocket heartbeat timeout, reconnecting...');
|
||||||
|
this.ws?.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.send({ type: 'ping' });
|
||||||
|
}
|
||||||
|
}, this.heartbeatTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopHeartbeat() {
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
this.heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private attemptReconnect() {
|
private attemptReconnect() {
|
||||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
console.error('Max reconnection attempts reached');
|
console.error('Max reconnection attempts reached');
|
||||||
@@ -236,6 +297,7 @@ export class WebSocketClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
this.stopHeartbeat();
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { WriteToolInput, EditToolInput, ToolInput } from '$lib/api';
|
||||||
|
|
||||||
|
export let tool: string;
|
||||||
|
export let input: ToolInput;
|
||||||
|
export let filePath: string;
|
||||||
|
|
||||||
|
$: isWrite = tool === 'Write';
|
||||||
|
$: isEdit = tool === 'Edit';
|
||||||
|
|
||||||
|
$: writeInput = input as WriteToolInput;
|
||||||
|
$: editInput = input as EditToolInput;
|
||||||
|
|
||||||
|
function getFileExtension(path: string): string {
|
||||||
|
const match = path.match(/\.([^.]+)$/);
|
||||||
|
return match ? match[1].toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLines(text: string): string[] {
|
||||||
|
if (!text) return [];
|
||||||
|
return text.split('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
$: extension = getFileExtension(filePath);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="diff-container bg-zinc-900 rounded-md overflow-hidden text-xs font-mono">
|
||||||
|
<!-- File header -->
|
||||||
|
<div class="diff-header bg-zinc-800 pl-1 pr-3 py-2 border-b border-zinc-700 flex items-center gap-2">
|
||||||
|
<span class="text-zinc-400">{isWrite ? 'New file' : 'Edit'}:</span>
|
||||||
|
<span class="text-zinc-200 truncate">{filePath}</span>
|
||||||
|
{#if extension}
|
||||||
|
<span class="text-zinc-500 text-[10px] uppercase bg-zinc-700 px-1.5 py-0.5 rounded"
|
||||||
|
>{extension}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Diff content -->
|
||||||
|
<div class="diff-content overflow-x-auto max-h-80 overflow-y-auto">
|
||||||
|
{#if isWrite}
|
||||||
|
<!-- New file: show all lines as additions -->
|
||||||
|
<table class="w-full">
|
||||||
|
<tbody>
|
||||||
|
{#each splitLines(writeInput.content) as line, i}
|
||||||
|
<tr class="diff-line addition">
|
||||||
|
<td class="line-number text-zinc-600 text-right px-1 py-0 select-none w-8 border-r border-zinc-800"
|
||||||
|
>{i + 1}</td
|
||||||
|
>
|
||||||
|
<td class="line-indicator text-green-500 px-0.5 w-3">+</td>
|
||||||
|
<td class="line-content text-green-300 pr-3 whitespace-pre">{line || ' '}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{:else if isEdit}
|
||||||
|
<!-- Edit: show old string as deletions, new string as additions -->
|
||||||
|
<table class="w-full">
|
||||||
|
<tbody>
|
||||||
|
<!-- Deletions (old_string) -->
|
||||||
|
{#each splitLines(editInput.old_string) as line, i}
|
||||||
|
<tr class="diff-line deletion bg-red-950/30">
|
||||||
|
<td class="line-number text-zinc-600 text-right px-1 py-0 select-none w-8 border-r border-zinc-800"
|
||||||
|
>{i + 1}</td
|
||||||
|
>
|
||||||
|
<td class="line-indicator text-red-500 px-0.5 w-3">-</td>
|
||||||
|
<td class="line-content text-red-300 pr-3 whitespace-pre">{line || ' '}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
<!-- Separator -->
|
||||||
|
{#if splitLines(editInput.old_string).length > 0 && splitLines(editInput.new_string).length > 0}
|
||||||
|
<tr class="diff-separator">
|
||||||
|
<td colspan="3" class="bg-zinc-800 h-px"></td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
<!-- Additions (new_string) -->
|
||||||
|
{#each splitLines(editInput.new_string) as line, i}
|
||||||
|
<tr class="diff-line addition bg-green-950/30">
|
||||||
|
<td class="line-number text-zinc-600 text-right px-1 py-0 select-none w-8 border-r border-zinc-800"
|
||||||
|
>{i + 1}</td
|
||||||
|
>
|
||||||
|
<td class="line-indicator text-green-500 px-0.5 w-3">+</td>
|
||||||
|
<td class="line-content text-green-300 pr-3 whitespace-pre">{line || ' '}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{:else}
|
||||||
|
<!-- Unknown tool type -->
|
||||||
|
<div class="p-3 text-zinc-400">
|
||||||
|
<pre class="whitespace-pre-wrap">{JSON.stringify(input, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.diff-line:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.addition:hover {
|
||||||
|
background-color: rgba(34, 197, 94, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.deletion:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-content {
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
|
|
||||||
let message = '';
|
let message = '';
|
||||||
let textarea: HTMLTextAreaElement;
|
let textarea: HTMLTextAreaElement;
|
||||||
|
let expanded = false;
|
||||||
|
|
||||||
|
const LINE_HEIGHT = 22; // approximate line height in pixels
|
||||||
|
const PADDING = 24; // vertical padding
|
||||||
|
const MIN_HEIGHT_COLLAPSED = LINE_HEIGHT + PADDING; // 1 line
|
||||||
|
const MIN_HEIGHT_EXPANDED = LINE_HEIGHT * 4 + PADDING; // 4 lines
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
const trimmed = message.trim();
|
const trimmed = message.trim();
|
||||||
@@ -15,6 +21,7 @@
|
|||||||
|
|
||||||
dispatch('send', trimmed);
|
dispatch('send', trimmed);
|
||||||
message = '';
|
message = '';
|
||||||
|
expanded = false;
|
||||||
resizeTextarea();
|
resizeTextarea();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +34,16 @@
|
|||||||
|
|
||||||
function resizeTextarea() {
|
function resizeTextarea() {
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
textarea.style.height = 'auto';
|
if (expanded) {
|
||||||
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
|
textarea.style.height = MIN_HEIGHT_EXPANDED + 'px';
|
||||||
|
} else {
|
||||||
|
textarea.style.height = MIN_HEIGHT_COLLAPSED + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded() {
|
||||||
|
expanded = !expanded;
|
||||||
|
resizeTextarea();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focus() {
|
export function focus() {
|
||||||
@@ -38,6 +53,22 @@
|
|||||||
|
|
||||||
<form on:submit|preventDefault={handleSubmit} class="border-t border-zinc-700 bg-zinc-900 safe-bottom">
|
<form on:submit|preventDefault={handleSubmit} class="border-t border-zinc-700 bg-zinc-900 safe-bottom">
|
||||||
<div class="flex items-end gap-2 p-3">
|
<div class="flex items-end gap-2 p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={toggleExpanded}
|
||||||
|
class="h-[44px] w-[44px] flex items-center justify-center text-zinc-500 hover:text-zinc-300 transition-colors flex-shrink-0"
|
||||||
|
aria-label={expanded ? 'Collapse input' : 'Expand input'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 transition-transform {expanded ? 'rotate-180' : ''}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
bind:this={textarea}
|
bind:this={textarea}
|
||||||
bind:value={message}
|
bind:value={message}
|
||||||
@@ -45,8 +76,8 @@
|
|||||||
on:input={resizeTextarea}
|
on:input={resizeTextarea}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{disabled}
|
{disabled}
|
||||||
rows="1"
|
rows={expanded ? 4 : 1}
|
||||||
class="input resize-none min-h-[44px] max-h-[150px] py-3"
|
class="input resize-none py-3 {expanded ? 'min-h-[112px]' : 'min-h-[44px]'}"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Message } from '$lib/api';
|
import type { Message, PermissionDenial, ToolInput } from '$lib/api';
|
||||||
import { onMount, afterUpdate } from 'svelte';
|
import { onMount, afterUpdate } from 'svelte';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import FileDiff from './FileDiff.svelte';
|
||||||
|
|
||||||
export let messages: Message[] = [];
|
export let messages: Message[] = [];
|
||||||
export let streamingContent: string = '';
|
export let streamingContent: string = '';
|
||||||
export let isThinking: boolean = false;
|
export let isThinking: boolean = false;
|
||||||
|
|
||||||
|
// Configure marked for safe rendering
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderMarkdown(content: string): string {
|
||||||
|
return marked.parse(content) as string;
|
||||||
|
}
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let autoScroll = true;
|
let autoScroll = true;
|
||||||
|
let collapsedMessages: Set<string> = new Set();
|
||||||
|
|
||||||
|
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
if (autoScroll && container) {
|
if (autoScroll && container) {
|
||||||
@@ -23,6 +38,34 @@
|
|||||||
autoScroll = distanceFromBottom < threshold;
|
autoScroll = distanceFromBottom < threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCollapse(id: string) {
|
||||||
|
if (collapsedMessages.has(id)) {
|
||||||
|
collapsedMessages.delete(id);
|
||||||
|
} else {
|
||||||
|
collapsedMessages.add(id);
|
||||||
|
}
|
||||||
|
collapsedMessages = collapsedMessages; // trigger reactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldCollapse(content: string): boolean {
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
return lines > COLLAPSE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function condenseAll() {
|
||||||
|
messages.forEach((msg) => {
|
||||||
|
if (shouldCollapse(msg.content)) {
|
||||||
|
collapsedMessages.add(msg.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
collapsedMessages = collapsedMessages; // trigger reactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandAll() {
|
||||||
|
collapsedMessages.clear();
|
||||||
|
collapsedMessages = collapsedMessages; // trigger reactivity
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
});
|
});
|
||||||
@@ -37,11 +80,45 @@
|
|||||||
system: 'bg-blue-500/20 border-blue-500/30 text-blue-200'
|
system: 'bg-blue-500/20 border-blue-500/30 text-blue-200'
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleLabels = {
|
function isPermissionAccepted(message: Message): boolean {
|
||||||
user: 'You',
|
return message.metadata?.type === 'permission-accepted';
|
||||||
assistant: 'Assistant',
|
}
|
||||||
system: 'System'
|
|
||||||
};
|
function isPermissionRequest(message: Message): boolean {
|
||||||
|
return message.metadata?.type === 'permission-request';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionStatus(message: Message): string | undefined {
|
||||||
|
return message.metadata?.status as string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionDenials(message: Message): PermissionDenial[] {
|
||||||
|
return (message.metadata?.denials as PermissionDenial[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileOperation(tool: string): boolean {
|
||||||
|
return tool === 'Write' || tool === 'Edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilePath(tool: string, input: ToolInput): string {
|
||||||
|
if ((tool === 'Write' || tool === 'Edit') && input && typeof input === 'object') {
|
||||||
|
return (input as { file_path?: string }).file_path || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track which denials are expanded in historical permission requests
|
||||||
|
let expandedHistoryDenials: Set<string> = new Set();
|
||||||
|
|
||||||
|
function toggleHistoryDenial(messageId: string, index: number) {
|
||||||
|
const key = `${messageId}-${index}`;
|
||||||
|
if (expandedHistoryDenials.has(key)) {
|
||||||
|
expandedHistoryDenials.delete(key);
|
||||||
|
} else {
|
||||||
|
expandedHistoryDenials.add(key);
|
||||||
|
}
|
||||||
|
expandedHistoryDenials = expandedHistoryDenials; // trigger reactivity
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -72,29 +149,135 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each messages as message (message.id)}
|
{#each messages as message (message.id)}
|
||||||
<div class="rounded-lg border p-3 {roleStyles[message.role]}">
|
{@const isCollapsed = collapsedMessages.has(message.id)}
|
||||||
<div class="flex items-center gap-2 mb-2">
|
{@const isCollapsible = shouldCollapse(message.content)}
|
||||||
<span
|
{@const permStatus = getPermissionStatus(message)}
|
||||||
class="text-xs font-semibold uppercase tracking-wide {message.role === 'user'
|
{#if isPermissionAccepted(message)}
|
||||||
? 'text-spice-400'
|
<!-- Permission accepted message (legacy format) -->
|
||||||
: 'text-zinc-400'}"
|
<div class="rounded-lg border p-3 bg-green-500/20 border-green-500/30">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<svg class="h-4 w-4 text-green-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<div class="text-sm text-green-200 font-mono space-y-0.5">
|
||||||
|
{#each message.content.split('\n') as line}
|
||||||
|
<div>{line}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if isPermissionRequest(message)}
|
||||||
|
<!-- Permission request message with file diffs -->
|
||||||
|
{@const denials = getPermissionDenials(message)}
|
||||||
|
{@const statusColor = permStatus === 'accept' ? 'green' : permStatus === 'deny' ? 'red' : permStatus === 'steer' ? 'amber' : 'amber'}
|
||||||
|
{@const bgColor = permStatus === 'accept' ? 'bg-green-500/10 border-green-500/30' : permStatus === 'deny' ? 'bg-red-500/10 border-red-500/30' : permStatus === 'steer' ? 'bg-amber-500/10 border-amber-500/30' : 'bg-amber-500/10 border-amber-500/30'}
|
||||||
|
<div class="rounded-lg border p-3 {bgColor}">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Status icon -->
|
||||||
|
<div class="flex-shrink-0 mt-0.5">
|
||||||
|
{#if permStatus === 'accept'}
|
||||||
|
<svg class="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
{:else if permStatus === 'deny'}
|
||||||
|
<svg class="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
{:else if permStatus === 'steer'}
|
||||||
|
<svg class="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Pending (shouldn't happen for historical, but fallback) -->
|
||||||
|
<svg class="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium {permStatus === 'accept' ? 'text-green-200' : permStatus === 'deny' ? 'text-red-200' : 'text-amber-200'}">
|
||||||
|
{#if permStatus === 'accept'}
|
||||||
|
Permission granted
|
||||||
|
{:else if permStatus === 'deny'}
|
||||||
|
Permission denied
|
||||||
|
{:else if permStatus === 'steer'}
|
||||||
|
Redirected
|
||||||
|
{:else}
|
||||||
|
Permission requested
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mt-2 space-y-2">
|
||||||
|
{#each denials as denial, index}
|
||||||
|
<li class="text-sm">
|
||||||
|
{#if isFileOperation(denial.tool)}
|
||||||
|
<!-- Expandable file operation -->
|
||||||
|
<button
|
||||||
|
class="w-full text-left flex items-center gap-2 text-zinc-300 font-mono hover:text-white transition-colors"
|
||||||
|
on:click={() => toggleHistoryDenial(message.id, index)}
|
||||||
>
|
>
|
||||||
{roleLabels[message.role]}
|
<svg
|
||||||
|
class="h-4 w-4 text-zinc-500 transition-transform {expandedHistoryDenials.has(`${message.id}-${index}`) ? 'rotate-90' : ''}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<span class="{permStatus === 'accept' ? 'text-green-400' : permStatus === 'deny' ? 'text-red-400' : 'text-amber-400'}">{denial.tool}:</span>
|
||||||
|
<span class="truncate">{denial.description}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expandedHistoryDenials.has(`${message.id}-${index}`)}
|
||||||
|
<div class="mt-2">
|
||||||
|
<FileDiff
|
||||||
|
tool={denial.tool}
|
||||||
|
input={denial.input}
|
||||||
|
filePath={getFilePath(denial.tool, denial.input)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- Non-file operation (Bash, etc) -->
|
||||||
|
<div class="flex items-center gap-2 text-zinc-300 font-mono">
|
||||||
|
<span class="{permStatus === 'accept' ? 'text-green-400' : permStatus === 'deny' ? 'text-red-400' : 'text-amber-400'}">{denial.tool}:</span>
|
||||||
|
<span class="truncate">{denial.description}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="rounded-lg border p-3 {roleStyles[message.role]} {isCollapsible ? 'cursor-pointer' : ''} relative"
|
||||||
|
on:click={() => isCollapsible && toggleCollapse(message.id)}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && isCollapsible && toggleCollapse(message.id)}
|
||||||
|
role={isCollapsible ? 'button' : undefined}
|
||||||
|
tabindex={isCollapsible ? 0 : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-sm break-words font-mono leading-relaxed markdown-content {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
|
||||||
|
>
|
||||||
|
{@html renderMarkdown(message.content)}
|
||||||
|
</div>
|
||||||
|
{#if isCollapsible}
|
||||||
|
<span
|
||||||
|
class="absolute right-3 top-3 text-zinc-500 transition-transform {isCollapsed ? '' : 'rotate-90'}"
|
||||||
|
>
|
||||||
|
›
|
||||||
</span>
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
|
{/if}
|
||||||
{message.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if isThinking && !streamingContent}
|
{#if isThinking && !streamingContent}
|
||||||
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
|
||||||
Assistant
|
|
||||||
</span>
|
|
||||||
<span class="flex gap-1">
|
|
||||||
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
|
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
|
||||||
<span
|
<span
|
||||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||||
@@ -104,31 +287,84 @@
|
|||||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||||
style="animation-delay: 0.2s"
|
style="animation-delay: 0.2s"
|
||||||
></span>
|
></span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if streamingContent}
|
{:else if streamingContent}
|
||||||
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="text-sm break-words font-mono leading-relaxed markdown-content">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
{@html renderMarkdown(streamingContent)}<span class="animate-pulse">|</span>
|
||||||
Assistant
|
|
||||||
</span>
|
|
||||||
<span class="flex gap-1">
|
|
||||||
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
|
|
||||||
<span
|
|
||||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
|
||||||
style="animation-delay: 0.1s"
|
|
||||||
></span>
|
|
||||||
<span
|
|
||||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
|
||||||
style="animation-delay: 0.2s"
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
|
|
||||||
{streamingContent}<span class="animate-pulse">|</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.markdown-content :global(p) {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.markdown-content :global(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.markdown-content :global(h1),
|
||||||
|
.markdown-content :global(h2),
|
||||||
|
.markdown-content :global(h3),
|
||||||
|
.markdown-content :global(h4) {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.markdown-content :global(h1) {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.markdown-content :global(h2) {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
.markdown-content :global(h3) {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.markdown-content :global(code) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.markdown-content :global(pre) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.markdown-content :global(pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.markdown-content :global(ul),
|
||||||
|
.markdown-content :global(ol) {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.markdown-content :global(li) {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.markdown-content :global(a) {
|
||||||
|
color: #f97316;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.markdown-content :global(a:hover) {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.markdown-content :global(blockquote) {
|
||||||
|
border-left: 3px solid #525252;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.markdown-content :global(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.markdown-content :global(em) {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import type { PermissionRequest } from '$lib/api';
|
import type { PermissionRequest, WriteToolInput, EditToolInput } from '$lib/api';
|
||||||
|
import FileDiff from './FileDiff.svelte';
|
||||||
|
|
||||||
export let permission: PermissionRequest;
|
export let permission: PermissionRequest;
|
||||||
|
export let assistantName: string = 'Assistant';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
accept: void;
|
accept: void;
|
||||||
@@ -10,6 +12,29 @@
|
|||||||
steer: void;
|
steer: void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// Track which denials are expanded
|
||||||
|
let expandedDenials: Set<number> = new Set();
|
||||||
|
|
||||||
|
function toggleDenial(index: number) {
|
||||||
|
if (expandedDenials.has(index)) {
|
||||||
|
expandedDenials.delete(index);
|
||||||
|
} else {
|
||||||
|
expandedDenials.add(index);
|
||||||
|
}
|
||||||
|
expandedDenials = expandedDenials; // trigger reactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileOperation(tool: string): boolean {
|
||||||
|
return tool === 'Write' || tool === 'Edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilePath(tool: string, input: unknown): string {
|
||||||
|
if ((tool === 'Write' || tool === 'Edit') && input && typeof input === 'object') {
|
||||||
|
return (input as { file_path?: string }).file_path || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function handleAccept() {
|
function handleAccept() {
|
||||||
dispatch('accept');
|
dispatch('accept');
|
||||||
}
|
}
|
||||||
@@ -42,13 +67,45 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium text-amber-200">Claude needs permission:</p>
|
<p class="text-sm font-medium text-amber-200">{assistantName} needs permission:</p>
|
||||||
|
|
||||||
<ul class="mt-2 space-y-1">
|
<ul class="mt-2 space-y-2">
|
||||||
{#each permission.denials as denial}
|
{#each permission.denials as denial, index}
|
||||||
<li class="text-sm text-zinc-300 font-mono truncate">
|
<li class="text-sm">
|
||||||
|
{#if isFileOperation(denial.tool)}
|
||||||
|
<!-- Expandable file operation -->
|
||||||
|
<button
|
||||||
|
class="w-full text-left flex items-center gap-2 text-zinc-300 font-mono hover:text-white transition-colors"
|
||||||
|
on:click={() => toggleDenial(index)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 text-zinc-500 transition-transform {expandedDenials.has(index) ? 'rotate-90' : ''}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
<span class="text-amber-400">{denial.tool}:</span>
|
<span class="text-amber-400">{denial.tool}:</span>
|
||||||
{denial.description}
|
<span class="truncate">{denial.description}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expandedDenials.has(index)}
|
||||||
|
<div class="mt-2">
|
||||||
|
<FileDiff
|
||||||
|
tool={denial.tool}
|
||||||
|
input={denial.input}
|
||||||
|
filePath={getFilePath(denial.tool, denial.input)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- Non-file operation (Bash, etc) -->
|
||||||
|
<div class="flex items-center gap-2 text-zinc-300 font-mono">
|
||||||
|
<span class="text-amber-400">{denial.tool}:</span>
|
||||||
|
<span class="truncate">{denial.description}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -56,19 +113,19 @@
|
|||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
on:click={handleAccept}
|
on:click={handleAccept}
|
||||||
class="btn bg-green-600 hover:bg-green-500 text-white text-sm px-3 py-1.5"
|
class="btn bg-green-600 hover:bg-green-500 text-white text-sm px-3 py-1.5 rounded"
|
||||||
>
|
>
|
||||||
Accept
|
Accept
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={handleDeny}
|
on:click={handleDeny}
|
||||||
class="btn bg-red-600 hover:bg-red-500 text-white text-sm px-3 py-1.5"
|
class="btn bg-red-600 hover:bg-red-500 text-white text-sm px-3 py-1.5 rounded"
|
||||||
>
|
>
|
||||||
Deny
|
Deny
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={handleSteer}
|
on:click={handleSteer}
|
||||||
class="btn bg-zinc-600 hover:bg-zinc-500 text-white text-sm px-3 py-1.5"
|
class="btn bg-zinc-600 hover:bg-zinc-500 text-white text-sm px-3 py-1.5 rounded"
|
||||||
>
|
>
|
||||||
No, and...
|
No, and...
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
$: workingDir = session['working-dir'] || session.workingDir || '';
|
$: workingDir = session['working-dir'] || session.workingDir || '';
|
||||||
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
|
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
|
||||||
$: shortId = externalId.slice(0, 8);
|
$: shortId = externalId.slice(0, 8);
|
||||||
$: projectName = workingDir.split('/').pop() || workingDir;
|
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@@ -33,10 +32,10 @@
|
|||||||
return 'Just now';
|
return 'Just now';
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors = {
|
const statusColors: Record<string, string> = {
|
||||||
idle: 'bg-zinc-600',
|
idle: 'bg-zinc-600',
|
||||||
running: 'bg-green-500 animate-pulse',
|
processing: 'bg-green-500 animate-pulse',
|
||||||
completed: 'bg-blue-500'
|
'awaiting-permission': 'bg-amber-500 animate-pulse'
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerColors = {
|
const providerColors = {
|
||||||
@@ -62,9 +61,9 @@
|
|||||||
{session.title || `Session ${shortId}`}
|
{session.title || `Session ${shortId}`}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{#if projectName}
|
{#if workingDir}
|
||||||
<p class="text-sm text-zinc-400 truncate mt-1">
|
<p class="text-sm text-zinc-400 mt-1 break-all">
|
||||||
{projectName}
|
{workingDir}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let autoAcceptEdits: boolean = false;
|
||||||
|
export let provider: 'claude' | 'opencode' = 'claude';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
toggleAutoAccept: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
const newValue = !autoAcceptEdits;
|
||||||
|
dispatch('toggleAutoAccept', newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.settings-dropdown')) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:click={handleClickOutside} />
|
||||||
|
|
||||||
|
<div class="relative settings-dropdown">
|
||||||
|
<button
|
||||||
|
on:click|stopPropagation={() => (open = !open)}
|
||||||
|
class="p-1.5 hover:bg-zinc-700 rounded transition-colors"
|
||||||
|
aria-label="Session settings"
|
||||||
|
title="Session settings"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl min-w-[240px] overflow-hidden z-50"
|
||||||
|
>
|
||||||
|
<div class="px-3 py-2 border-b border-zinc-700">
|
||||||
|
<span class="text-sm font-medium text-zinc-200">Session Settings</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if provider === 'claude'}
|
||||||
|
<label
|
||||||
|
class="flex items-start gap-3 px-3 py-2.5 hover:bg-zinc-700/50 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoAcceptEdits}
|
||||||
|
on:change={handleToggle}
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-spice-500 focus:ring-spice-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-sm text-zinc-200 block">Auto-accept edits</span>
|
||||||
|
<span class="text-xs text-zinc-500 block mt-0.5"
|
||||||
|
>Skip permission prompts for file changes</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<div class="px-3 py-2.5 text-xs text-zinc-500">
|
||||||
|
No settings available for OpenCode sessions yet.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -106,10 +106,13 @@ function createActiveSessionStore() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await api.getSession(id);
|
const session = await api.getSession(id);
|
||||||
|
// Load pending permission from session if it exists (persisted in DB)
|
||||||
|
const pendingPermission = session['pending-permission'] || session.pendingPermission || null;
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
session,
|
session,
|
||||||
messages: session.messages || [],
|
messages: session.messages || [],
|
||||||
|
pendingPermission,
|
||||||
loading: false
|
loading: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -174,8 +177,34 @@ function createActiveSessionStore() {
|
|||||||
const state = get();
|
const state = get();
|
||||||
if (!state.session || !state.pendingPermission) return;
|
if (!state.session || !state.pendingPermission) return;
|
||||||
|
|
||||||
// Clear pending permission immediately
|
const permission = state.pendingPermission as PermissionRequest & { 'message-id'?: string };
|
||||||
update((s) => ({ ...s, pendingPermission: null }));
|
const messageId = permission['message-id'];
|
||||||
|
|
||||||
|
// Show loading indicator while LLM processes the permission response
|
||||||
|
// (unless user is steering, which requires them to provide a message)
|
||||||
|
const showThinking = response !== 'steer' || message;
|
||||||
|
|
||||||
|
// Update the permission message's status locally
|
||||||
|
// The server will also persist this, but we update locally for immediate feedback
|
||||||
|
if (messageId) {
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
messages: s.messages.map((msg) => {
|
||||||
|
if (msg.id === messageId) {
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
metadata: { ...msg.metadata, status: response }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}),
|
||||||
|
pendingPermission: null,
|
||||||
|
isThinking: showThinking ? true : s.isThinking
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// No message-id (legacy), just clear pending permission
|
||||||
|
update((s) => ({ ...s, pendingPermission: null, isThinking: showThinking ? true : s.isThinking }));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.respondToPermission(state.session.id, response, message);
|
await api.respondToPermission(state.session.id, response, message);
|
||||||
@@ -201,6 +230,26 @@ function createActiveSessionStore() {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async setAutoAcceptEdits(enabled: boolean) {
|
||||||
|
const state = get();
|
||||||
|
if (!state.session) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await api.updateSession(state.session.id, {
|
||||||
|
'auto-accept-edits': enabled
|
||||||
|
});
|
||||||
|
update((s) => ({
|
||||||
|
...s,
|
||||||
|
session: s.session ? { ...s.session, ...updated } : null
|
||||||
|
}));
|
||||||
|
// Also update in the sessions list
|
||||||
|
sessions.updateSession(state.session.id, { 'auto-accept-edits': enabled });
|
||||||
|
return updated;
|
||||||
|
} catch (e) {
|
||||||
|
update((s) => ({ ...s, error: (e as Error).message }));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
clear() {
|
clear() {
|
||||||
if (unsubscribeWs) {
|
if (unsubscribeWs) {
|
||||||
unsubscribeWs();
|
unsubscribeWs();
|
||||||
@@ -225,6 +274,7 @@ function createActiveSessionStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleStreamEvent(event: StreamEvent) {
|
function handleStreamEvent(event: StreamEvent) {
|
||||||
|
console.log('[WS] Received event:', event.event, event);
|
||||||
if (event.event === 'init' && event.cwd) {
|
if (event.event === 'init' && event.cwd) {
|
||||||
// Update session's working directory from init event
|
// Update session's working directory from init event
|
||||||
update((s) => {
|
update((s) => {
|
||||||
@@ -234,6 +284,26 @@ function createActiveSessionStore() {
|
|||||||
session: { ...s.session, 'working-dir': event.cwd, workingDir: event.cwd }
|
session: { ...s.session, 'working-dir': event.cwd, workingDir: event.cwd }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
// Also update in the sessions list
|
||||||
|
const state = get();
|
||||||
|
if (state.session) {
|
||||||
|
sessions.updateSession(state.session.id, { 'working-dir': event.cwd });
|
||||||
|
}
|
||||||
|
} else if (event.event === 'working-dir-update' && event['working-dir']) {
|
||||||
|
// Update session's working directory when detected from tool results
|
||||||
|
const newDir = event['working-dir'];
|
||||||
|
update((s) => {
|
||||||
|
if (!s.session) return s;
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
session: { ...s.session, 'working-dir': newDir, workingDir: newDir }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Also update in the sessions list
|
||||||
|
const state = get();
|
||||||
|
if (state.session) {
|
||||||
|
sessions.updateSession(state.session.id, { 'working-dir': newDir });
|
||||||
|
}
|
||||||
} else if (event.event === 'content-delta' && event.text) {
|
} else if (event.event === 'content-delta' && event.text) {
|
||||||
update((s) => ({ ...s, streamingContent: s.streamingContent + event.text, isThinking: false }));
|
update((s) => ({ ...s, streamingContent: s.streamingContent + event.text, isThinking: false }));
|
||||||
} else if (event.event === 'message-stop') {
|
} else if (event.event === 'message-stop') {
|
||||||
@@ -257,8 +327,25 @@ function createActiveSessionStore() {
|
|||||||
});
|
});
|
||||||
} else if (event.event === 'permission-request') {
|
} else if (event.event === 'permission-request') {
|
||||||
const permReq = event['permission-request'] || event.permissionRequest;
|
const permReq = event['permission-request'] || event.permissionRequest;
|
||||||
|
const permMessage = (event as StreamEvent & { message?: Message }).message;
|
||||||
|
const messageId = (event as StreamEvent & { 'message-id'?: string })['message-id'];
|
||||||
|
console.log('[WS] Permission request received:', permReq, 'message:', permMessage);
|
||||||
if (permReq) {
|
if (permReq) {
|
||||||
update((s) => ({ ...s, pendingPermission: permReq }));
|
// Store the message-id in the permission request for later status update
|
||||||
|
const permReqWithMsgId = messageId ? { ...permReq, 'message-id': messageId } : permReq;
|
||||||
|
update((s) => {
|
||||||
|
// If we received the full message, add it to messages array
|
||||||
|
// Otherwise just update pendingPermission
|
||||||
|
if (permMessage) {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
pendingPermission: permReqWithMsgId,
|
||||||
|
messages: [...s.messages, permMessage]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...s, pendingPermission: permReqWithMsgId };
|
||||||
|
});
|
||||||
|
console.log('[WS] pendingPermission state updated with message-id:', messageId);
|
||||||
}
|
}
|
||||||
} else if (event.event === 'error') {
|
} else if (event.event === 'error') {
|
||||||
update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false }));
|
update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false }));
|
||||||
@@ -278,6 +365,6 @@ export const sortedSessions: Readable<Session[]> = derived(sessions, ($sessions)
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const runningSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
|
export const processingSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
|
||||||
$sessions.sessions.filter((s) => s.status === 'running')
|
$sessions.sessions.filter((s) => s.status === 'processing')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { sessions, sortedSessions, runningSessions } from '$lib/stores/sessions';
|
import { sessions, sortedSessions, processingSessions } from '$lib/stores/sessions';
|
||||||
import type { Session } from '$lib/api';
|
import type { Session } from '$lib/api';
|
||||||
import SessionCard from '$lib/components/SessionCard.svelte';
|
import SessionCard from '$lib/components/SessionCard.svelte';
|
||||||
|
|
||||||
@@ -111,10 +111,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $runningSessions.length > 0}
|
{#if $processingSessions.length > 0}
|
||||||
<div class="mt-2 px-2 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
|
<div class="mt-2 px-2 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||||
<span class="text-xs text-green-400">
|
<span class="text-xs text-green-400">
|
||||||
{$runningSessions.length} session{$runningSessions.length === 1 ? '' : 's'} running
|
{$processingSessions.length} session{$processingSessions.length === 1 ? '' : 's'} processing
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,14 +6,17 @@
|
|||||||
import MessageList from '$lib/components/MessageList.svelte';
|
import MessageList from '$lib/components/MessageList.svelte';
|
||||||
import InputBar from '$lib/components/InputBar.svelte';
|
import InputBar from '$lib/components/InputBar.svelte';
|
||||||
import PermissionRequest from '$lib/components/PermissionRequest.svelte';
|
import PermissionRequest from '$lib/components/PermissionRequest.svelte';
|
||||||
|
import SessionSettings from '$lib/components/SessionSettings.svelte';
|
||||||
|
|
||||||
$: sessionId = $page.params.id;
|
$: sessionId = $page.params.id;
|
||||||
|
|
||||||
let inputBar: InputBar;
|
let inputBar: InputBar;
|
||||||
|
let messageList: MessageList;
|
||||||
let steerMode = false;
|
let steerMode = false;
|
||||||
let isEditingTitle = false;
|
let isEditingTitle = false;
|
||||||
let editedTitle = '';
|
let editedTitle = '';
|
||||||
let titleInput: HTMLInputElement;
|
let titleInput: HTMLInputElement;
|
||||||
|
let menuOpen = false;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@@ -90,11 +93,17 @@
|
|||||||
$: shortId = externalId ? externalId.slice(0, 8) : session?.id?.slice(0, 8) || '';
|
$: shortId = externalId ? externalId.slice(0, 8) : session?.id?.slice(0, 8) || '';
|
||||||
$: projectName = workingDir.split('/').pop() || '';
|
$: projectName = workingDir.split('/').pop() || '';
|
||||||
$: isNewSession = !externalId && $activeSession.messages.length === 0;
|
$: isNewSession = !externalId && $activeSession.messages.length === 0;
|
||||||
|
$: assistantName = session?.provider === 'opencode' ? 'OpenCode' : 'Claude';
|
||||||
|
$: autoAcceptEdits = session?.['auto-accept-edits'] || session?.autoAcceptEdits || false;
|
||||||
|
|
||||||
|
function handleToggleAutoAccept(event: CustomEvent<boolean>) {
|
||||||
|
activeSession.setAutoAcceptEdits(event.detail);
|
||||||
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
idle: 'bg-zinc-600',
|
idle: 'bg-zinc-600',
|
||||||
running: 'bg-green-500 animate-pulse',
|
processing: 'bg-green-500 animate-pulse',
|
||||||
completed: 'bg-blue-500'
|
'awaiting-permission': 'bg-amber-500 animate-pulse'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -102,8 +111,10 @@
|
|||||||
<title>{session?.title || `Session ${shortId}`} - Spiceflow</title>
|
<title>{session?.title || `Session ${shortId}`} - Spiceflow</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<!-- Header -->
|
<svelte:window on:click={() => (menuOpen = false)} />
|
||||||
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
|
|
||||||
|
<!-- Header - Full (portrait) -->
|
||||||
|
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3 landscape-mobile:hidden">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
on:click={goBack}
|
on:click={goBack}
|
||||||
@@ -148,6 +159,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SessionSettings
|
||||||
|
{autoAcceptEdits}
|
||||||
|
provider={session.provider}
|
||||||
|
on:toggleAutoAccept={handleToggleAutoAccept}
|
||||||
|
/>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="text-xs font-medium uppercase {session.provider === 'claude'
|
class="text-xs font-medium uppercase {session.provider === 'claude'
|
||||||
? 'text-spice-400'
|
? 'text-spice-400'
|
||||||
@@ -159,6 +176,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Header - Collapsed (landscape mobile) -->
|
||||||
|
<div class="landscape-menu fixed top-2 right-2 z-50">
|
||||||
|
<button
|
||||||
|
on:click|stopPropagation={() => (menuOpen = !menuOpen)}
|
||||||
|
class="p-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg border border-zinc-700 transition-colors"
|
||||||
|
aria-label="Menu"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if menuOpen}
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||||
|
<div on:click|stopPropagation class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl min-w-[200px] overflow-hidden">
|
||||||
|
{#if session}
|
||||||
|
<div class="px-3 py-2 border-b border-zinc-700">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full {statusColors[session.status] || statusColors.idle}"></span>
|
||||||
|
<span class="font-semibold truncate">{session.title || `Session ${shortId}`}</span>
|
||||||
|
</div>
|
||||||
|
{#if projectName}
|
||||||
|
<p class="text-xs text-zinc-500 truncate mt-0.5">{projectName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
on:click={() => { menuOpen = false; goBack(); }}
|
||||||
|
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to sessions
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => { menuOpen = false; messageList?.condenseAll(); }}
|
||||||
|
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
Condense all
|
||||||
|
</button>
|
||||||
|
{#if session?.provider === 'claude'}
|
||||||
|
<label class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors cursor-pointer border-t border-zinc-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoAcceptEdits}
|
||||||
|
on:change={() => activeSession.setAutoAcceptEdits(!autoAcceptEdits)}
|
||||||
|
class="h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-spice-500 focus:ring-spice-500 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span>Auto-accept edits</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
{#if $activeSession.error}
|
{#if $activeSession.error}
|
||||||
<div class="flex-1 flex items-center justify-center p-4">
|
<div class="flex-1 flex items-center justify-center p-4">
|
||||||
@@ -181,18 +257,26 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if workingDir}
|
{#if workingDir}
|
||||||
<div class="flex-shrink-0 px-4 py-2 bg-zinc-900/50 border-b border-zinc-800 flex items-center gap-2 text-xs text-zinc-500">
|
<div class="flex-shrink-0 px-4 py-2 bg-zinc-900/50 border-b border-zinc-800 flex items-center gap-2 text-xs text-zinc-500 landscape-mobile:hidden">
|
||||||
<svg class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="truncate font-mono">{workingDir}</span>
|
<span class="truncate font-mono">{workingDir}</span>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<button
|
||||||
|
on:click={() => messageList?.condenseAll()}
|
||||||
|
class="text-zinc-400 hover:text-zinc-200 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
condense all
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<MessageList messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} />
|
<MessageList bind:this={messageList} messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} />
|
||||||
|
|
||||||
{#if $activeSession.pendingPermission}
|
{#if $activeSession.pendingPermission}
|
||||||
<PermissionRequest
|
<PermissionRequest
|
||||||
permission={$activeSession.pendingPermission}
|
permission={$activeSession.pendingPermission}
|
||||||
|
{assistantName}
|
||||||
on:accept={handlePermissionAccept}
|
on:accept={handlePermissionAccept}
|
||||||
on:deny={handlePermissionDeny}
|
on:deny={handlePermissionDeny}
|
||||||
on:steer={handlePermissionSteer}
|
on:steer={handlePermissionSteer}
|
||||||
@@ -202,10 +286,10 @@
|
|||||||
<InputBar
|
<InputBar
|
||||||
bind:this={inputBar}
|
bind:this={inputBar}
|
||||||
on:send={handleSend}
|
on:send={handleSend}
|
||||||
disabled={session?.status === 'running' && $activeSession.streamingContent !== ''}
|
disabled={session?.status === 'processing' && $activeSession.streamingContent !== ''}
|
||||||
placeholder={steerMode
|
placeholder={steerMode
|
||||||
? 'Tell Claude what to do instead...'
|
? `Tell ${assistantName} what to do instead...`
|
||||||
: session?.status === 'running'
|
: session?.status === 'processing'
|
||||||
? 'Waiting for response...'
|
? 'Waiting for response...'
|
||||||
: 'Type a message...'}
|
: 'Type a message...'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export default {
|
|||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace']
|
mono: ['JetBrains Mono', 'Fira Code', 'monospace']
|
||||||
|
},
|
||||||
|
screens: {
|
||||||
|
'landscape-mobile': { raw: '(orientation: landscape) and (max-height: 500px)' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default defineConfig({
|
|||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: `http://localhost:${process.env.VITE_BACKEND_PORT || 3000}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true
|
ws: true
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -23,10 +23,12 @@ npm run test:ui
|
|||||||
|
|
||||||
The e2e setup automatically manages both servers:
|
The e2e setup automatically manages both servers:
|
||||||
|
|
||||||
1. **Global Setup** (`global-setup.ts`) - Starts backend (port 3000) and frontend (port 5173)
|
1. **Global Setup** (`global-setup.ts`) - Starts backend (port 3001) and frontend (port 5174)
|
||||||
2. **Global Teardown** (`global-teardown.ts`) - Stops both servers
|
2. **Global Teardown** (`global-teardown.ts`) - Stops both servers
|
||||||
3. **Server Utils** (`server-utils.ts`) - Spawns Clojure and Vite processes, waits for health checks
|
3. **Server Utils** (`server-utils.ts`) - Spawns Clojure and Vite processes, waits for health checks
|
||||||
|
|
||||||
|
E2E tests use different ports (3001/5174) than dev servers (3000/5173) to allow running tests without interfering with development.
|
||||||
|
|
||||||
Tests use Playwright's `page` fixture for browser interactions and `request` fixture for direct API calls.
|
Tests use Playwright's `page` fixture for browser interactions and `request` fixture for direct API calls.
|
||||||
|
|
||||||
## Test Database
|
## Test Database
|
||||||
@@ -37,12 +39,13 @@ E2E tests use a separate database (`server/test-e2e.db`) to avoid polluting the
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { E2E_BACKEND_URL } from '../playwright.config';
|
||||||
|
|
||||||
test('example', async ({ page, request }) => {
|
test('example', async ({ page, request }) => {
|
||||||
// Direct API call
|
// Direct API call - use E2E_BACKEND_URL for backend requests
|
||||||
const response = await request.get('http://localhost:3000/api/sessions');
|
const response = await request.get(`${E2E_BACKEND_URL}/api/sessions`);
|
||||||
|
|
||||||
// Browser interaction
|
// Browser interaction - baseURL is configured in playwright.config.ts
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.locator('h1')).toBeVisible();
|
await expect(page.locator('h1')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-1
@@ -1,12 +1,30 @@
|
|||||||
import { startServers } from './server-utils.js';
|
import { startServers } from './server-utils.js';
|
||||||
|
import { E2E_BACKEND_PORT, E2E_FRONTEND_PORT } from './playwright.config.js';
|
||||||
|
import { unlinkSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
function cleanupTestFiles() {
|
||||||
|
const testFile = join(homedir(), 'foo.md');
|
||||||
|
try {
|
||||||
|
unlinkSync(testFile);
|
||||||
|
console.log('Cleaned up test file:', testFile);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function globalSetup() {
|
export default async function globalSetup() {
|
||||||
|
// Clean up test files from previous runs
|
||||||
|
cleanupTestFiles();
|
||||||
|
|
||||||
// Skip if servers are managed externally (e.g., by scripts/test)
|
// Skip if servers are managed externally (e.g., by scripts/test)
|
||||||
if (process.env.SKIP_SERVER_SETUP) {
|
if (process.env.SKIP_SERVER_SETUP) {
|
||||||
console.log('\n=== Skipping server setup (SKIP_SERVER_SETUP is set) ===\n');
|
console.log('\n=== Skipping server setup (SKIP_SERVER_SETUP is set) ===\n');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('\n=== Starting E2E Test Environment ===\n');
|
console.log('\n=== Starting E2E Test Environment ===\n');
|
||||||
await startServers(3000, 5173);
|
console.log(`Backend port: ${E2E_BACKEND_PORT}, Frontend port: ${E2E_FRONTEND_PORT}`);
|
||||||
|
await startServers(E2E_BACKEND_PORT, E2E_FRONTEND_PORT);
|
||||||
console.log('\n=== E2E Test Environment Ready ===\n');
|
console.log('\n=== E2E Test Environment Ready ===\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { stopServers } from './server-utils.js';
|
import { stopServers } from './server-utils.js';
|
||||||
|
import { unlinkSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
function cleanupTestFiles() {
|
||||||
|
const testFile = join(homedir(), 'foo.md');
|
||||||
|
try {
|
||||||
|
unlinkSync(testFile);
|
||||||
|
console.log('Cleaned up test file:', testFile);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function globalTeardown() {
|
export default async function globalTeardown() {
|
||||||
|
// Clean up test files
|
||||||
|
cleanupTestFiles();
|
||||||
|
|
||||||
// Skip if servers are managed externally (e.g., by scripts/test)
|
// Skip if servers are managed externally (e.g., by scripts/test)
|
||||||
if (process.env.SKIP_SERVER_SETUP) {
|
if (process.env.SKIP_SERVER_SETUP) {
|
||||||
console.log('\n=== Skipping server teardown (SKIP_SERVER_SETUP is set) ===\n');
|
console.log('\n=== Skipping server teardown (SKIP_SERVER_SETUP is set) ===\n');
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
// E2E uses different ports to avoid conflicts with dev servers
|
||||||
|
export const E2E_BACKEND_PORT = 3001;
|
||||||
|
export const E2E_FRONTEND_PORT = 5174;
|
||||||
|
export const E2E_BACKEND_URL = `http://localhost:${E2E_BACKEND_PORT}`;
|
||||||
|
export const E2E_FRONTEND_URL = `https://localhost:${E2E_FRONTEND_PORT}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
@@ -9,7 +15,7 @@ export default defineConfig({
|
|||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'https://localhost:5173',
|
baseURL: E2E_FRONTEND_URL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export async function startFrontend(port = 5173, backendPort = 3000): Promise<Ch
|
|||||||
cwd: CLIENT_DIR,
|
cwd: CLIENT_DIR,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
VITE_BACKEND_PORT: String(backendPort),
|
||||||
},
|
},
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { E2E_BACKEND_URL } from '../playwright.config';
|
||||||
|
|
||||||
test.describe('Basic E2E Tests', () => {
|
test.describe('Basic E2E Tests', () => {
|
||||||
test('backend health check', async ({ request }) => {
|
test('backend health check', async ({ request }) => {
|
||||||
const response = await request.get('http://localhost:3000/api/health');
|
const response = await request.get(`${E2E_BACKEND_URL}/api/health`);
|
||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
expect(body.status).toBe('ok');
|
expect(body.status).toBe('ok');
|
||||||
@@ -15,7 +16,7 @@ test.describe('Basic E2E Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sessions list loads empty', async ({ request }) => {
|
test('sessions list loads empty', async ({ request }) => {
|
||||||
const response = await request.get('http://localhost:3000/api/sessions');
|
const response = await request.get(`${E2E_BACKEND_URL}/api/sessions`);
|
||||||
expect(response.ok()).toBeTruthy();
|
expect(response.ok()).toBeTruthy();
|
||||||
const sessions = await response.json();
|
const sessions = await response.json();
|
||||||
expect(Array.isArray(sessions)).toBeTruthy();
|
expect(Array.isArray(sessions)).toBeTruthy();
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('OpenCode File Workflow', () => {
|
||||||
|
test('create, read, and delete file without permission prompts', async ({ page }) => {
|
||||||
|
// Increase timeout for this test since it involves multiple AI interactions
|
||||||
|
test.setTimeout(180000);
|
||||||
|
|
||||||
|
// Enable console logging for debugging
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
console.log(`[Browser ${msg.type()}]`, msg.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log WebSocket frames for debugging
|
||||||
|
page.on('websocket', (ws) => {
|
||||||
|
console.log(`[WebSocket] Connected to ${ws.url()}`);
|
||||||
|
ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload));
|
||||||
|
ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Navigate to homepage
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
|
||||||
|
// 2. Click the + button to open new session menu
|
||||||
|
const createButton = page.locator('button[title="New Session"]');
|
||||||
|
await expect(createButton).toBeVisible();
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
// 3. Select OpenCode from the dropdown
|
||||||
|
const opencodeOption = page.locator('button:has-text("OpenCode")');
|
||||||
|
await expect(opencodeOption).toBeVisible();
|
||||||
|
await opencodeOption.click();
|
||||||
|
|
||||||
|
// 4. Wait for navigation to session page
|
||||||
|
await page.waitForURL(/\/session\/.+/);
|
||||||
|
console.log('[Test] Navigated to session page:', page.url());
|
||||||
|
|
||||||
|
// 5. Wait for the page to load
|
||||||
|
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('text=No messages yet')).toBeVisible();
|
||||||
|
|
||||||
|
const textarea = page.locator('textarea');
|
||||||
|
const sendButton = page.locator('button[type="submit"]');
|
||||||
|
const bouncingDots = page.locator('.animate-bounce');
|
||||||
|
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
|
||||||
|
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
|
||||||
|
|
||||||
|
// Messages with .markdown-content are rendered assistant/user messages
|
||||||
|
const messagesWithContent = page.locator('.rounded-lg.border').filter({
|
||||||
|
has: page.locator('.markdown-content')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to wait for streaming to complete
|
||||||
|
const waitForStreamingComplete = async () => {
|
||||||
|
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
||||||
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STEP 1: Create a file
|
||||||
|
// ============================================================
|
||||||
|
console.log('[Test] Step 1: Creating file');
|
||||||
|
|
||||||
|
await expect(textarea).toBeVisible();
|
||||||
|
await textarea.fill(
|
||||||
|
'Create a file called test-opencode.md with the content "Hello from OpenCode test". Just create the file, no other commentary.'
|
||||||
|
);
|
||||||
|
await expect(sendButton).toBeEnabled();
|
||||||
|
await sendButton.click();
|
||||||
|
|
||||||
|
// Verify user message appears
|
||||||
|
await expect(page.locator('text=Create a file called test-opencode.md')).toBeVisible();
|
||||||
|
console.log('[Test] User message displayed');
|
||||||
|
|
||||||
|
// Verify thinking indicator appears (bouncing dots)
|
||||||
|
await expect(bouncingDots.first()).toBeVisible({ timeout: 2000 });
|
||||||
|
console.log('[Test] Thinking indicator appeared');
|
||||||
|
|
||||||
|
// OpenCode should NOT show permission request - it auto-approves
|
||||||
|
// Wait a moment to ensure no permission UI appears
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const permissionUI = page.locator('text=needs permission');
|
||||||
|
await expect(permissionUI).not.toBeVisible();
|
||||||
|
console.log('[Test] Confirmed no permission prompt for file creation');
|
||||||
|
|
||||||
|
// Wait for streaming to complete
|
||||||
|
await waitForStreamingComplete();
|
||||||
|
console.log('[Test] Step 1 complete: File created');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STEP 2: Read the file
|
||||||
|
// ============================================================
|
||||||
|
console.log('[Test] Step 2: Reading file');
|
||||||
|
|
||||||
|
const messageCountAfterCreate = await messagesWithContent.count();
|
||||||
|
|
||||||
|
await textarea.fill(
|
||||||
|
'Read the contents of test-opencode.md and tell me exactly what it says.'
|
||||||
|
);
|
||||||
|
await sendButton.click();
|
||||||
|
|
||||||
|
// Wait for new assistant message
|
||||||
|
await expect(messagesWithContent).toHaveCount(messageCountAfterCreate + 1, { timeout: 60000 });
|
||||||
|
console.log('[Test] New assistant message appeared for read');
|
||||||
|
|
||||||
|
// Wait for streaming to complete
|
||||||
|
await waitForStreamingComplete();
|
||||||
|
|
||||||
|
// Verify the response contains the file content
|
||||||
|
const readResponseMessage = messagesWithContent.last();
|
||||||
|
const readResponseText = await readResponseMessage.locator('.markdown-content').textContent();
|
||||||
|
console.log('[Test] OpenCode read back:', readResponseText);
|
||||||
|
|
||||||
|
expect(readResponseText).toBeTruthy();
|
||||||
|
expect(readResponseText).toContain('Hello from OpenCode test');
|
||||||
|
console.log('[Test] Step 2 complete: File content verified');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STEP 3: Delete the file
|
||||||
|
// ============================================================
|
||||||
|
console.log('[Test] Step 3: Deleting file');
|
||||||
|
|
||||||
|
const messageCountAfterRead = await messagesWithContent.count();
|
||||||
|
|
||||||
|
await textarea.fill(
|
||||||
|
'Delete the file test-opencode.md. Confirm when done.'
|
||||||
|
);
|
||||||
|
await sendButton.click();
|
||||||
|
|
||||||
|
// Wait for new assistant message
|
||||||
|
await expect(messagesWithContent).toHaveCount(messageCountAfterRead + 1, { timeout: 60000 });
|
||||||
|
console.log('[Test] New assistant message appeared for delete');
|
||||||
|
|
||||||
|
// OpenCode should NOT show permission request for delete either
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await expect(permissionUI).not.toBeVisible();
|
||||||
|
console.log('[Test] Confirmed no permission prompt for file deletion');
|
||||||
|
|
||||||
|
// Wait for streaming to complete
|
||||||
|
await waitForStreamingComplete();
|
||||||
|
|
||||||
|
// Verify delete confirmation
|
||||||
|
const deleteResponseMessage = messagesWithContent.last();
|
||||||
|
const deleteResponseText = await deleteResponseMessage.locator('.markdown-content').textContent();
|
||||||
|
console.log('[Test] OpenCode delete response:', deleteResponseText);
|
||||||
|
|
||||||
|
expect(deleteResponseText).toBeTruthy();
|
||||||
|
// Response should indicate the file was deleted (various phrasings possible)
|
||||||
|
expect(deleteResponseText!.toLowerCase()).toMatch(/delet|remov|done|success/);
|
||||||
|
console.log('[Test] Step 3 complete: File deleted');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STEP 4: Verify file is gone
|
||||||
|
// ============================================================
|
||||||
|
console.log('[Test] Step 4: Verifying file no longer exists');
|
||||||
|
|
||||||
|
const messageCountAfterDelete = await messagesWithContent.count();
|
||||||
|
|
||||||
|
await textarea.fill(
|
||||||
|
'Try to read test-opencode.md again. Does it exist?'
|
||||||
|
);
|
||||||
|
await sendButton.click();
|
||||||
|
|
||||||
|
// Wait for new assistant message
|
||||||
|
await expect(messagesWithContent).toHaveCount(messageCountAfterDelete + 1, { timeout: 60000 });
|
||||||
|
|
||||||
|
// Wait for streaming to complete
|
||||||
|
await waitForStreamingComplete();
|
||||||
|
|
||||||
|
// Verify the response indicates file doesn't exist
|
||||||
|
const verifyResponseMessage = messagesWithContent.last();
|
||||||
|
const verifyResponseText = await verifyResponseMessage.locator('.markdown-content').textContent();
|
||||||
|
console.log('[Test] OpenCode verify response:', verifyResponseText);
|
||||||
|
|
||||||
|
expect(verifyResponseText).toBeTruthy();
|
||||||
|
// Response should indicate file doesn't exist
|
||||||
|
expect(verifyResponseText!.toLowerCase()).toMatch(/not exist|not found|no such|doesn't exist|does not exist|cannot find|can't find/);
|
||||||
|
console.log('[Test] Step 4 complete: Confirmed file no longer exists');
|
||||||
|
|
||||||
|
console.log('[Test] All steps completed successfully!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Permissions Workflow', () => {
|
test.describe('Claude Permissions Workflow', () => {
|
||||||
test('permission approval allows file creation and reading', async ({ page }) => {
|
test('permission approval allows file creation and reading', async ({ page }) => {
|
||||||
// Increase timeout for this test since it involves real Claude interaction
|
// Increase timeout for this test since it involves real Claude interaction
|
||||||
test.setTimeout(180000);
|
test.setTimeout(180000);
|
||||||
@@ -21,20 +21,25 @@ test.describe('Permissions Workflow', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page).toHaveTitle(/Spiceflow/i);
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
|
||||||
// 2. Create a new session
|
// 2. Click the + button to open new session menu
|
||||||
const createButton = page.locator('button[title="New Session"]');
|
const createButton = page.locator('button[title="New Session"]');
|
||||||
await expect(createButton).toBeVisible();
|
await expect(createButton).toBeVisible();
|
||||||
await createButton.click();
|
await createButton.click();
|
||||||
|
|
||||||
// 3. Wait for navigation to session page
|
// 3. Select Claude Code from the dropdown
|
||||||
|
const claudeOption = page.locator('button:has-text("Claude Code")');
|
||||||
|
await expect(claudeOption).toBeVisible();
|
||||||
|
await claudeOption.click();
|
||||||
|
|
||||||
|
// 4. Wait for navigation to session page
|
||||||
await page.waitForURL(/\/session\/.+/);
|
await page.waitForURL(/\/session\/.+/);
|
||||||
console.log('[Test] Navigated to session page:', page.url());
|
console.log('[Test] Navigated to session page:', page.url());
|
||||||
|
|
||||||
// 4. Wait for the page to load
|
// 5. Wait for the page to load
|
||||||
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
||||||
await expect(page.locator('text=No messages yet')).toBeVisible();
|
await expect(page.locator('text=No messages yet')).toBeVisible();
|
||||||
|
|
||||||
// 5. Send message asking Claude to create foo.md with a haiku
|
// 6. Send message asking Claude to create foo.md with a haiku
|
||||||
const textarea = page.locator('textarea');
|
const textarea = page.locator('textarea');
|
||||||
await expect(textarea).toBeVisible();
|
await expect(textarea).toBeVisible();
|
||||||
await textarea.fill(
|
await textarea.fill(
|
||||||
@@ -45,73 +50,75 @@ test.describe('Permissions Workflow', () => {
|
|||||||
await expect(sendButton).toBeEnabled();
|
await expect(sendButton).toBeEnabled();
|
||||||
await sendButton.click();
|
await sendButton.click();
|
||||||
|
|
||||||
// 6. Verify user message appears
|
// 7. Verify user message appears
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('text=Create a file called foo.md')
|
page.locator('text=Create a file called foo.md')
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
console.log('[Test] User message displayed');
|
console.log('[Test] User message displayed');
|
||||||
|
|
||||||
// 6b. Verify thinking indicator appears immediately
|
// 7b. Verify thinking indicator appears immediately
|
||||||
const assistantBubble = page.locator('.rounded-lg.border').filter({
|
// The thinking indicator shows bouncing dots
|
||||||
has: page.locator('text=Assistant')
|
const bouncingDotsIndicator = page.locator('.animate-bounce');
|
||||||
}).first();
|
await expect(bouncingDotsIndicator.first()).toBeVisible({ timeout: 2000 });
|
||||||
await expect(assistantBubble).toBeVisible({ timeout: 2000 });
|
|
||||||
console.log('[Test] Thinking indicator appeared immediately');
|
console.log('[Test] Thinking indicator appeared immediately');
|
||||||
|
|
||||||
// 7. Wait for permission request UI to appear
|
// 8. Wait for permission request UI to appear
|
||||||
const permissionUI = page.locator('text=Claude needs permission');
|
const permissionUI = page.locator('text=Claude needs permission');
|
||||||
await expect(permissionUI).toBeVisible({ timeout: 60000 });
|
await expect(permissionUI).toBeVisible({ timeout: 60000 });
|
||||||
console.log('[Test] Permission request UI appeared');
|
console.log('[Test] Permission request UI appeared');
|
||||||
|
|
||||||
// 8. Verify the permission shows Write tool for foo.md
|
// 9. Verify the permission shows Write tool for foo.md
|
||||||
const permissionDescription = page.locator('li.font-mono').filter({
|
// The permission UI has li > button.font-mono or li > div.font-mono with "Write:" and filename
|
||||||
|
const permissionDescription = page.locator('.font-mono').filter({
|
||||||
hasText: /Write.*foo\.md|create.*foo\.md/i
|
hasText: /Write.*foo\.md|create.*foo\.md/i
|
||||||
}).first();
|
}).first();
|
||||||
await expect(permissionDescription).toBeVisible();
|
await expect(permissionDescription).toBeVisible();
|
||||||
console.log('[Test] Permission shows foo.md file creation');
|
console.log('[Test] Permission shows foo.md file creation');
|
||||||
|
|
||||||
// 9. Click Accept button
|
// 10. Click Accept button
|
||||||
const acceptButton = page.locator('button:has-text("Accept")');
|
const acceptButton = page.locator('button:has-text("Accept")');
|
||||||
await expect(acceptButton).toBeVisible();
|
await expect(acceptButton).toBeVisible();
|
||||||
await acceptButton.click();
|
await acceptButton.click();
|
||||||
console.log('[Test] Clicked Accept button');
|
console.log('[Test] Clicked Accept button');
|
||||||
|
|
||||||
// 10. Wait for permission UI to disappear
|
// 11. Wait for permission UI to disappear
|
||||||
await expect(permissionUI).not.toBeVisible({ timeout: 10000 });
|
await expect(permissionUI).not.toBeVisible({ timeout: 10000 });
|
||||||
console.log('[Test] Permission UI disappeared');
|
console.log('[Test] Permission UI disappeared');
|
||||||
|
|
||||||
// 11. Wait for streaming to complete after permission granted
|
// 12. Wait for streaming to complete after permission granted
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
const bouncingDots = page.locator('.animate-bounce');
|
const bouncingDots = page.locator('.animate-bounce');
|
||||||
const pulsingCursor = page.locator('.animate-pulse');
|
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
|
||||||
|
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
|
||||||
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
||||||
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
||||||
console.log('[Test] First streaming complete');
|
console.log('[Test] First streaming complete');
|
||||||
|
|
||||||
// Count current assistant messages before sending new request
|
// Count current messages with markdown-content before sending new request
|
||||||
|
// Assistant messages have .markdown-content inside the bubble
|
||||||
const assistantMessages = page.locator('.rounded-lg.border').filter({
|
const assistantMessages = page.locator('.rounded-lg.border').filter({
|
||||||
has: page.locator('text=Assistant')
|
has: page.locator('.markdown-content')
|
||||||
});
|
});
|
||||||
const messageCountBefore = await assistantMessages.count();
|
const messageCountBefore = await assistantMessages.count();
|
||||||
console.log('[Test] Assistant message count before read request:', messageCountBefore);
|
console.log('[Test] Message count before read request:', messageCountBefore);
|
||||||
|
|
||||||
// 12. Now ask Claude to read the file back to verify it was created
|
// 13. Now ask Claude to read the file back to verify it was created
|
||||||
await textarea.fill('Read the contents of foo.md and tell me what it says. Quote the file contents.');
|
await textarea.fill('Read the contents of foo.md and tell me what it says. Quote the file contents.');
|
||||||
await sendButton.click();
|
await sendButton.click();
|
||||||
console.log('[Test] Asked Claude to read the file');
|
console.log('[Test] Asked Claude to read the file');
|
||||||
|
|
||||||
// 13. Wait for a NEW assistant message to appear
|
// 14. Wait for a NEW message to appear
|
||||||
await expect(assistantMessages).toHaveCount(messageCountBefore + 1, { timeout: 60000 });
|
await expect(assistantMessages).toHaveCount(messageCountBefore + 1, { timeout: 60000 });
|
||||||
console.log('[Test] New assistant message appeared');
|
console.log('[Test] New message appeared');
|
||||||
|
|
||||||
// Wait for streaming to complete on the new message
|
// Wait for streaming to complete on the new message
|
||||||
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
||||||
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
||||||
console.log('[Test] Second streaming complete');
|
console.log('[Test] Second streaming complete');
|
||||||
|
|
||||||
// 14. Verify the response contains "My Haiku" - confirming file was created and read
|
// 15. Verify the response contains "My Haiku" - confirming file was created and read
|
||||||
const lastAssistantMessage = assistantMessages.last();
|
const lastAssistantMessage = assistantMessages.last();
|
||||||
const responseText = await lastAssistantMessage.locator('.font-mono').textContent();
|
const responseText = await lastAssistantMessage.locator('.markdown-content').textContent();
|
||||||
console.log('[Test] Claude read back:', responseText);
|
console.log('[Test] Claude read back:', responseText);
|
||||||
|
|
||||||
// The response should contain "My Haiku" which we asked Claude to title the file
|
// The response should contain "My Haiku" which we asked Claude to title the file
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { E2E_BACKEND_URL } from '../playwright.config';
|
||||||
|
|
||||||
|
test.describe('Claude Working Directory Auto-Update', () => {
|
||||||
|
test('working directory updates automatically after cd command', async ({ page, request }) => {
|
||||||
|
// Increase timeout for this test since it involves real Claude interaction
|
||||||
|
test.setTimeout(180000);
|
||||||
|
|
||||||
|
// Enable console logging to debug issues
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
console.log(`[Browser ${msg.type()}]`, msg.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log WebSocket frames
|
||||||
|
page.on('websocket', (ws) => {
|
||||||
|
console.log(`[WebSocket] Connected to ${ws.url()}`);
|
||||||
|
ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload));
|
||||||
|
ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload));
|
||||||
|
ws.on('close', () => console.log('[WebSocket] Closed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log network requests to /api
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (req.url().includes('/api')) {
|
||||||
|
console.log(`[Request] ${req.method()} ${req.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.url().includes('/api')) {
|
||||||
|
console.log(`[Response] ${response.status()} ${response.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Navigate to homepage
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
|
||||||
|
// 2. Click the + button to open new session menu
|
||||||
|
const createButton = page.locator('button[title="New Session"]');
|
||||||
|
await expect(createButton).toBeVisible();
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
// 3. Select Claude Code from the dropdown
|
||||||
|
const claudeOption = page.locator('button:has-text("Claude Code")');
|
||||||
|
await expect(claudeOption).toBeVisible();
|
||||||
|
await claudeOption.click();
|
||||||
|
|
||||||
|
// 4. Wait for navigation to session page
|
||||||
|
await page.waitForURL(/\/session\/.+/);
|
||||||
|
const sessionUrl = page.url();
|
||||||
|
const sessionId = sessionUrl.split('/session/')[1];
|
||||||
|
console.log('[Test] Navigated to session page:', sessionUrl);
|
||||||
|
console.log('[Test] Session ID:', sessionId);
|
||||||
|
|
||||||
|
// 5. Wait for the page to load (no loading spinner)
|
||||||
|
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 6. Verify we see the empty message state
|
||||||
|
await expect(page.locator('text=No messages yet')).toBeVisible();
|
||||||
|
|
||||||
|
// 7. Send a message to Claude asking it to cd into repos (natural language)
|
||||||
|
// Claude should run the cd command and ideally output the current directory
|
||||||
|
const textarea = page.locator('textarea');
|
||||||
|
await expect(textarea).toBeVisible();
|
||||||
|
await textarea.fill('change directory to ~/repos and tell me where you are now');
|
||||||
|
|
||||||
|
// 8. Click the send button
|
||||||
|
const sendButton = page.locator('button[type="submit"]');
|
||||||
|
await expect(sendButton).toBeEnabled();
|
||||||
|
await sendButton.click();
|
||||||
|
|
||||||
|
// 9. Wait for streaming to complete
|
||||||
|
const bouncingDots = page.locator('.animate-bounce');
|
||||||
|
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
|
||||||
|
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
|
||||||
|
await expect(bouncingDots).toHaveCount(0, { timeout: 60000 });
|
||||||
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 });
|
||||||
|
console.log('[Test] Message complete');
|
||||||
|
|
||||||
|
// 10. The working directory bar should now show the repos path (automatically updated)
|
||||||
|
// The working dir bar is in a specific container with bg-zinc-900/50
|
||||||
|
const workingDirBar = page.locator('div.bg-zinc-900\\/50');
|
||||||
|
await expect(workingDirBar).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// The working dir text is in a span.truncate.font-mono inside the bar
|
||||||
|
const workingDirText = workingDirBar.locator('span.truncate.font-mono');
|
||||||
|
await expect(workingDirText).toBeVisible();
|
||||||
|
|
||||||
|
// 11. Wait for the working directory to contain 'repos' (automatic update from tool result)
|
||||||
|
await expect(workingDirText).toContainText('repos', { timeout: 10000 });
|
||||||
|
const displayedWorkingDir = await workingDirText.textContent();
|
||||||
|
console.log('[Test] Working directory in UI:', displayedWorkingDir);
|
||||||
|
expect(displayedWorkingDir).toContain('repos');
|
||||||
|
|
||||||
|
// 12. Verify the working directory in the database via API
|
||||||
|
const sessionResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${sessionId}`);
|
||||||
|
expect(sessionResponse.ok()).toBeTruthy();
|
||||||
|
const sessionData = await sessionResponse.json();
|
||||||
|
console.log('[Test] Session data from API:', JSON.stringify(sessionData, null, 2));
|
||||||
|
|
||||||
|
// The API returns session data directly (not nested under 'session')
|
||||||
|
const dbWorkingDir = sessionData['working-dir'] || sessionData.workingDir || '';
|
||||||
|
console.log('[Test] Working directory from DB:', dbWorkingDir);
|
||||||
|
|
||||||
|
// DB should have the repos path
|
||||||
|
expect(dbWorkingDir).toContain('repos');
|
||||||
|
|
||||||
|
// UI and DB should match
|
||||||
|
expect(displayedWorkingDir).toBe(dbWorkingDir);
|
||||||
|
|
||||||
|
console.log('[Test] Auto-sync test passed - working directory automatically updated to repos path');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Claude Chat Workflow', () => {
|
||||||
|
test('create new chat and send message to Claude', async ({ page }) => {
|
||||||
|
// Enable console logging to debug WebSocket issues
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
console.log(`[Browser ${msg.type()}]`, msg.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log WebSocket frames
|
||||||
|
page.on('websocket', (ws) => {
|
||||||
|
console.log(`[WebSocket] Connected to ${ws.url()}`);
|
||||||
|
ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload));
|
||||||
|
ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload));
|
||||||
|
ws.on('close', () => console.log('[WebSocket] Closed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log network requests to /api
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('/api')) {
|
||||||
|
console.log(`[Request] ${request.method()} ${request.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.url().includes('/api')) {
|
||||||
|
console.log(`[Response] ${response.status()} ${response.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Navigate to homepage
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
|
||||||
|
// 2. Click the + button to open new session menu
|
||||||
|
const createButton = page.locator('button[title="New Session"]');
|
||||||
|
await expect(createButton).toBeVisible();
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
// 3. Select Claude Code from the dropdown
|
||||||
|
const claudeOption = page.locator('button:has-text("Claude Code")');
|
||||||
|
await expect(claudeOption).toBeVisible();
|
||||||
|
await claudeOption.click();
|
||||||
|
|
||||||
|
// 4. Wait for navigation to session page
|
||||||
|
await page.waitForURL(/\/session\/.+/);
|
||||||
|
console.log('[Test] Navigated to session page:', page.url());
|
||||||
|
|
||||||
|
// 5. Wait for the page to load (no loading spinner)
|
||||||
|
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 6. Verify we see the empty message state
|
||||||
|
await expect(page.locator('text=No messages yet')).toBeVisible();
|
||||||
|
|
||||||
|
// 7. Type a message in the textarea
|
||||||
|
const textarea = page.locator('textarea');
|
||||||
|
await expect(textarea).toBeVisible();
|
||||||
|
await textarea.fill('say hi. respond in a single concise sentence');
|
||||||
|
|
||||||
|
// 8. Click the send button
|
||||||
|
const sendButton = page.locator('button[type="submit"]');
|
||||||
|
await expect(sendButton).toBeEnabled();
|
||||||
|
await sendButton.click();
|
||||||
|
|
||||||
|
// 9. Verify user message appears immediately (optimistic update)
|
||||||
|
await expect(page.locator('text=say hi. respond in a single concise sentence')).toBeVisible();
|
||||||
|
console.log('[Test] User message displayed');
|
||||||
|
|
||||||
|
// 10. Verify thinking indicator appears immediately after sending
|
||||||
|
// The thinking indicator is a .rounded-lg.border div with bouncing dots inside
|
||||||
|
const bouncingDots = page.locator('.animate-bounce');
|
||||||
|
|
||||||
|
// Thinking indicator should appear almost immediately (within 2 seconds)
|
||||||
|
await expect(bouncingDots.first()).toBeVisible({ timeout: 2000 });
|
||||||
|
console.log('[Test] Thinking indicator appeared immediately');
|
||||||
|
|
||||||
|
// 11. Wait for streaming to complete - progress indicator should disappear
|
||||||
|
// The streaming indicator has animate-bounce dots and animate-pulse cursor
|
||||||
|
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
|
||||||
|
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
|
||||||
|
|
||||||
|
// Wait for streaming indicators to disappear (they should be gone after message-stop)
|
||||||
|
await expect(bouncingDots).toHaveCount(0, { timeout: 30000 });
|
||||||
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 });
|
||||||
|
console.log('[Test] Streaming complete - progress indicator disappeared');
|
||||||
|
|
||||||
|
// 12. Verify the response contains some text content
|
||||||
|
// The assistant message is the last .rounded-lg.border with .markdown-content inside
|
||||||
|
const assistantMessage = page.locator('.rounded-lg.border').filter({
|
||||||
|
has: page.locator('.markdown-content')
|
||||||
|
}).last();
|
||||||
|
const responseText = await assistantMessage.locator('.markdown-content').textContent();
|
||||||
|
console.log('[Test] Assistant response text:', responseText);
|
||||||
|
expect(responseText).toBeTruthy();
|
||||||
|
expect(responseText!.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 13. Verify working directory indicator appears
|
||||||
|
// The working directory should be captured from the init event and displayed
|
||||||
|
const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first();
|
||||||
|
await expect(workingDirIndicator).toBeVisible({ timeout: 5000 });
|
||||||
|
const workingDirText = await workingDirIndicator.textContent();
|
||||||
|
console.log('[Test] Working directory displayed:', workingDirText);
|
||||||
|
expect(workingDirText).toMatch(/^\//); // Should start with /
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Chat Workflow', () => {
|
test.describe('OpenCode Chat Workflow', () => {
|
||||||
test('create new chat and send message to Claude', async ({ page }) => {
|
// Skip: OpenCode (Go binary) has stdout buffering issues when run as subprocess from Java
|
||||||
|
// Go binaries ignore stdbuf and require a pseudo-terminal for proper streaming
|
||||||
|
test.skip('create new chat and send message to OpenCode', async ({ page }) => {
|
||||||
// Enable console logging to debug WebSocket issues
|
// Enable console logging to debug WebSocket issues
|
||||||
page.on('console', (msg) => {
|
page.on('console', (msg) => {
|
||||||
console.log(`[Browser ${msg.type()}]`, msg.text());
|
console.log(`[Browser ${msg.type()}]`, msg.text());
|
||||||
@@ -31,36 +33,41 @@ test.describe('Chat Workflow', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page).toHaveTitle(/Spiceflow/i);
|
await expect(page).toHaveTitle(/Spiceflow/i);
|
||||||
|
|
||||||
// 2. Click the + button to create a new session
|
// 2. Click the + button to open new session menu
|
||||||
const createButton = page.locator('button[title="New Session"]');
|
const createButton = page.locator('button[title="New Session"]');
|
||||||
await expect(createButton).toBeVisible();
|
await expect(createButton).toBeVisible();
|
||||||
await createButton.click();
|
await createButton.click();
|
||||||
|
|
||||||
// 3. Wait for navigation to session page
|
// 3. Select OpenCode from the dropdown
|
||||||
|
const opencodeOption = page.locator('button:has-text("OpenCode")');
|
||||||
|
await expect(opencodeOption).toBeVisible();
|
||||||
|
await opencodeOption.click();
|
||||||
|
|
||||||
|
// 4. Wait for navigation to session page
|
||||||
await page.waitForURL(/\/session\/.+/);
|
await page.waitForURL(/\/session\/.+/);
|
||||||
console.log('[Test] Navigated to session page:', page.url());
|
console.log('[Test] Navigated to session page:', page.url());
|
||||||
|
|
||||||
// 4. Wait for the page to load (no loading spinner)
|
// 5. Wait for the page to load (no loading spinner)
|
||||||
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// 5. Verify we see the empty message state
|
// 6. Verify we see the empty message state
|
||||||
await expect(page.locator('text=No messages yet')).toBeVisible();
|
await expect(page.locator('text=No messages yet')).toBeVisible();
|
||||||
|
|
||||||
// 6. Type a message in the textarea
|
// 7. Type a message in the textarea
|
||||||
const textarea = page.locator('textarea');
|
const textarea = page.locator('textarea');
|
||||||
await expect(textarea).toBeVisible();
|
await expect(textarea).toBeVisible();
|
||||||
await textarea.fill('say hi. respond in a single concise sentence');
|
await textarea.fill('say hi. respond in a single concise sentence');
|
||||||
|
|
||||||
// 7. Click the send button
|
// 8. Click the send button
|
||||||
const sendButton = page.locator('button[type="submit"]');
|
const sendButton = page.locator('button[type="submit"]');
|
||||||
await expect(sendButton).toBeEnabled();
|
await expect(sendButton).toBeEnabled();
|
||||||
await sendButton.click();
|
await sendButton.click();
|
||||||
|
|
||||||
// 8. Verify user message appears immediately (optimistic update)
|
// 9. Verify user message appears immediately (optimistic update)
|
||||||
await expect(page.locator('text=say hi. respond in a single concise sentence')).toBeVisible();
|
await expect(page.locator('text=say hi. respond in a single concise sentence')).toBeVisible();
|
||||||
console.log('[Test] User message displayed');
|
console.log('[Test] User message displayed');
|
||||||
|
|
||||||
// 9. Verify thinking indicator appears immediately after sending
|
// 10. Verify thinking indicator appears immediately after sending
|
||||||
// The assistant bubble with bouncing dots should show right away (isThinking state)
|
// The assistant bubble with bouncing dots should show right away (isThinking state)
|
||||||
const assistantMessage = page.locator('.rounded-lg.border').filter({
|
const assistantMessage = page.locator('.rounded-lg.border').filter({
|
||||||
has: page.locator('text=Assistant')
|
has: page.locator('text=Assistant')
|
||||||
@@ -75,25 +82,26 @@ test.describe('Chat Workflow', () => {
|
|||||||
await expect(bouncingDotsInAssistant.first()).toBeVisible({ timeout: 2000 });
|
await expect(bouncingDotsInAssistant.first()).toBeVisible({ timeout: 2000 });
|
||||||
console.log('[Test] Bouncing dots visible in thinking state');
|
console.log('[Test] Bouncing dots visible in thinking state');
|
||||||
|
|
||||||
// 10. Wait for streaming to complete - progress indicator should disappear
|
// 11. Wait for streaming to complete - progress indicator should disappear
|
||||||
// The streaming indicator has animate-bounce dots and animate-pulse cursor
|
// The streaming indicator has animate-bounce dots and animate-pulse cursor
|
||||||
// Note: With fast responses, the indicator may appear and disappear quickly,
|
// Note: With fast responses, the indicator may appear and disappear quickly,
|
||||||
// so we just verify it's gone after the response is visible
|
// so we just verify it's gone after the response is visible
|
||||||
const bouncingDots = page.locator('.animate-bounce');
|
const bouncingDots = page.locator('.animate-bounce');
|
||||||
const pulsingCursor = page.locator('.animate-pulse');
|
// Only look for pulsing cursor inside markdown-content (not the header status indicator)
|
||||||
|
const pulsingCursor = page.locator('.markdown-content .animate-pulse');
|
||||||
|
|
||||||
// Wait for streaming indicators to disappear (they should be gone after message-stop)
|
// Wait for streaming indicators to disappear (they should be gone after message-stop)
|
||||||
await expect(bouncingDots).toHaveCount(0, { timeout: 30000 });
|
await expect(bouncingDots).toHaveCount(0, { timeout: 30000 });
|
||||||
await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 });
|
await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 });
|
||||||
console.log('[Test] Streaming complete - progress indicator disappeared');
|
console.log('[Test] Streaming complete - progress indicator disappeared');
|
||||||
|
|
||||||
// 11. Verify the response contains some text content
|
// 12. Verify the response contains some text content
|
||||||
const responseText = await assistantMessage.locator('.font-mono').textContent();
|
const responseText = await assistantMessage.locator('.font-mono').textContent();
|
||||||
console.log('[Test] Assistant response text:', responseText);
|
console.log('[Test] Assistant response text:', responseText);
|
||||||
expect(responseText).toBeTruthy();
|
expect(responseText).toBeTruthy();
|
||||||
expect(responseText!.length).toBeGreaterThan(0);
|
expect(responseText!.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// 12. Verify working directory indicator appears
|
// 13. Verify working directory indicator appears
|
||||||
// The working directory should be captured from the init event and displayed
|
// The working directory should be captured from the init event and displayed
|
||||||
const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first();
|
const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first();
|
||||||
await expect(workingDirIndicator).toBeVisible({ timeout: 5000 });
|
await expect(workingDirIndicator).toBeVisible({ timeout: 5000 });
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# My Haiku:
|
||||||
|
|
||||||
|
Wires cross and spark
|
||||||
|
Agents dance to their own tune
|
||||||
|
My wishes fade fast
|
||||||
+45
-8
@@ -1,7 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Development script - runs backend and frontend concurrently
|
# Development script - runs backend REPL with auto-reload and frontend
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
@@ -13,21 +11,56 @@ YELLOW='\033[1;33m'
|
|||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
BACKEND_PID=""
|
||||||
|
FRONTEND_PID=""
|
||||||
|
NREPL_PORT=7888
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo -e "\n${YELLOW}Shutting down...${NC}"
|
echo -e "\n${YELLOW}Shutting down...${NC}"
|
||||||
|
|
||||||
|
# Kill backend and all its children
|
||||||
|
if [ -n "$BACKEND_PID" ]; then
|
||||||
|
pkill -P $BACKEND_PID 2>/dev/null || true
|
||||||
kill $BACKEND_PID 2>/dev/null || true
|
kill $BACKEND_PID 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill frontend and all its children
|
||||||
|
if [ -n "$FRONTEND_PID" ]; then
|
||||||
|
pkill -P $FRONTEND_PID 2>/dev/null || true
|
||||||
kill $FRONTEND_PID 2>/dev/null || true
|
kill $FRONTEND_PID 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also kill any orphaned processes on our ports
|
||||||
|
fuser -k 3000/tcp 2>/dev/null || true
|
||||||
|
fuser -k 5173/tcp 2>/dev/null || true
|
||||||
|
fuser -k $NREPL_PORT/tcp 2>/dev/null || true
|
||||||
|
|
||||||
|
wait 2>/dev/null || true
|
||||||
|
echo -e "${GREEN}Stopped${NC}"
|
||||||
|
|
||||||
|
# Restore terminal
|
||||||
|
stty sane 2>/dev/null || true
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
trap cleanup SIGINT SIGTERM
|
trap cleanup SIGINT SIGTERM SIGHUP EXIT
|
||||||
|
|
||||||
echo -e "${BLUE}=== Starting Spiceflow Development Environment ===${NC}\n"
|
echo -e "${BLUE}=== Starting Spiceflow Development Environment ===${NC}\n"
|
||||||
|
|
||||||
# Start backend
|
# Start backend REPL with auto-reload
|
||||||
echo -e "${GREEN}Starting backend server...${NC}"
|
echo -e "${GREEN}Starting backend REPL with auto-reload...${NC}"
|
||||||
cd "$ROOT_DIR/server"
|
cd "$ROOT_DIR/server"
|
||||||
clj -M:run &
|
|
||||||
|
# Start nREPL server and run (go) to start app with file watcher
|
||||||
|
clj -M:dev -e "
|
||||||
|
(require 'nrepl.server)
|
||||||
|
(def server (nrepl.server/start-server :port $NREPL_PORT))
|
||||||
|
(println \"nREPL server started on port $NREPL_PORT\")
|
||||||
|
(require 'user)
|
||||||
|
(user/go)
|
||||||
|
;; Block forever to keep the process running
|
||||||
|
@(promise)
|
||||||
|
" &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
# Wait for backend to be ready
|
# Wait for backend to be ready
|
||||||
@@ -36,6 +69,8 @@ until curl -s http://localhost:3000/api/health > /dev/null 2>&1; do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo -e "${GREEN}Backend ready on http://localhost:3000${NC}"
|
echo -e "${GREEN}Backend ready on http://localhost:3000${NC}"
|
||||||
|
echo -e "${GREEN}nREPL available on port $NREPL_PORT${NC}"
|
||||||
|
echo -e "${GREEN}Auto-reload enabled - editing .clj files will trigger reload${NC}"
|
||||||
|
|
||||||
# Start frontend
|
# Start frontend
|
||||||
echo -e "${GREEN}Starting frontend server...${NC}"
|
echo -e "${GREEN}Starting frontend server...${NC}"
|
||||||
@@ -55,9 +90,11 @@ LOCAL_IP=$(hostname -I | awk '{print $1}')
|
|||||||
|
|
||||||
echo -e "\n${BLUE}=== Spiceflow Development Environment Ready ===${NC}"
|
echo -e "\n${BLUE}=== Spiceflow Development Environment Ready ===${NC}"
|
||||||
echo -e "${GREEN}Backend:${NC} http://localhost:3000"
|
echo -e "${GREEN}Backend:${NC} http://localhost:3000"
|
||||||
|
echo -e "${GREEN}nREPL:${NC} localhost:$NREPL_PORT"
|
||||||
echo -e "${GREEN}Frontend:${NC} https://localhost:5173"
|
echo -e "${GREEN}Frontend:${NC} https://localhost:5173"
|
||||||
echo -e "${GREEN}Phone:${NC} https://${LOCAL_IP}:5173"
|
echo -e "${GREEN}Phone:${NC} https://${LOCAL_IP}:5173"
|
||||||
echo -e "\nPress Ctrl+C to stop\n"
|
echo -e "\n${YELLOW}Auto-reload is active. Edit any .clj file to trigger reload.${NC}"
|
||||||
|
echo -e "Press Ctrl+C to stop\n"
|
||||||
|
|
||||||
# Wait for processes
|
# Wait for processes
|
||||||
wait
|
wait
|
||||||
|
|||||||
+10
-6
@@ -39,31 +39,35 @@ echo -e "${BLUE}=== Starting Spiceflow Test Environment ===${NC}\n"
|
|||||||
# Clean up old test database
|
# Clean up old test database
|
||||||
rm -f "$ROOT_DIR/server/test-e2e.db"
|
rm -f "$ROOT_DIR/server/test-e2e.db"
|
||||||
|
|
||||||
|
# Use different ports for e2e tests (matching playwright.config.ts)
|
||||||
|
E2E_BACKEND_PORT=3001
|
||||||
|
E2E_FRONTEND_PORT=5174
|
||||||
|
|
||||||
# Start backend with test database
|
# Start backend with test database
|
||||||
echo -e "${GREEN}Starting backend server with test database...${NC}"
|
echo -e "${GREEN}Starting backend server with test database...${NC}"
|
||||||
cd "$ROOT_DIR/server"
|
cd "$ROOT_DIR/server"
|
||||||
SPICEFLOW_DB="test-e2e.db" clj -M:run &
|
SPICEFLOW_DB="test-e2e.db" SPICEFLOW_PORT=$E2E_BACKEND_PORT clj -M:run &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
# Wait for backend to be ready
|
# Wait for backend to be ready
|
||||||
echo -e "${YELLOW}Waiting for backend...${NC}"
|
echo -e "${YELLOW}Waiting for backend...${NC}"
|
||||||
until curl -s http://localhost:3000/api/health > /dev/null 2>&1; do
|
until curl -s http://localhost:$E2E_BACKEND_PORT/api/health > /dev/null 2>&1; do
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo -e "${GREEN}Backend ready on http://localhost:3000${NC}"
|
echo -e "${GREEN}Backend ready on http://localhost:$E2E_BACKEND_PORT${NC}"
|
||||||
|
|
||||||
# Start frontend
|
# Start frontend
|
||||||
echo -e "${GREEN}Starting frontend server...${NC}"
|
echo -e "${GREEN}Starting frontend server...${NC}"
|
||||||
cd "$ROOT_DIR/client"
|
cd "$ROOT_DIR/client"
|
||||||
npm run dev -- --port 5173 &
|
VITE_API_URL="http://localhost:$E2E_BACKEND_PORT" npm run dev -- --port $E2E_FRONTEND_PORT &
|
||||||
FRONTEND_PID=$!
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
# Wait for frontend to be ready
|
# Wait for frontend to be ready
|
||||||
echo -e "${YELLOW}Waiting for frontend...${NC}"
|
echo -e "${YELLOW}Waiting for frontend...${NC}"
|
||||||
until curl -sk https://localhost:5173 > /dev/null 2>&1; do
|
until curl -sk https://localhost:$E2E_FRONTEND_PORT > /dev/null 2>&1; do
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo -e "${GREEN}Frontend ready on https://localhost:5173${NC}\n"
|
echo -e "${GREEN}Frontend ready on https://localhost:$E2E_FRONTEND_PORT${NC}\n"
|
||||||
|
|
||||||
# Run e2e tests
|
# Run e2e tests
|
||||||
echo -e "${BLUE}=== Running E2E Tests ===${NC}\n"
|
echo -e "${BLUE}=== Running E2E Tests ===${NC}\n"
|
||||||
|
|||||||
+6
-1
@@ -32,4 +32,9 @@
|
|||||||
:main-opts ["-m" "kaocha.runner"]}
|
:main-opts ["-m" "kaocha.runner"]}
|
||||||
:repl {:main-opts ["-m" "nrepl.cmdline" "-i"]
|
:repl {:main-opts ["-m" "nrepl.cmdline" "-i"]
|
||||||
:extra-deps {nrepl/nrepl {:mvn/version "1.1.0"}
|
:extra-deps {nrepl/nrepl {:mvn/version "1.1.0"}
|
||||||
cider/cider-nrepl {:mvn/version "0.44.0"}}}}}
|
cider/cider-nrepl {:mvn/version "0.44.0"}}}
|
||||||
|
:dev {:extra-paths ["dev"]
|
||||||
|
:extra-deps {nrepl/nrepl {:mvn/version "1.1.0"}
|
||||||
|
cider/cider-nrepl {:mvn/version "0.44.0"}
|
||||||
|
org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||||
|
hawk/hawk {:mvn/version "0.2.11"}}}}}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
(ns user
|
||||||
|
(:require [clojure.tools.namespace.repl :as repl]
|
||||||
|
[clojure.tools.logging :as log]
|
||||||
|
[mount.core :as mount]
|
||||||
|
[hawk.core :as hawk]))
|
||||||
|
|
||||||
|
;; Only reload spiceflow namespaces
|
||||||
|
(repl/set-refresh-dirs "src")
|
||||||
|
|
||||||
|
(defonce watcher (atom nil))
|
||||||
|
|
||||||
|
(defn start
|
||||||
|
"Start the application."
|
||||||
|
[]
|
||||||
|
(require 'spiceflow.core)
|
||||||
|
((resolve 'spiceflow.core/-main)))
|
||||||
|
|
||||||
|
(defn stop
|
||||||
|
"Stop the application."
|
||||||
|
[]
|
||||||
|
(mount/stop))
|
||||||
|
|
||||||
|
(defn reset
|
||||||
|
"Stop, reload all changed namespaces, and restart."
|
||||||
|
[]
|
||||||
|
(stop)
|
||||||
|
(repl/refresh :after 'user/start))
|
||||||
|
|
||||||
|
(defn reload
|
||||||
|
"Reload all changed namespaces without restarting."
|
||||||
|
[]
|
||||||
|
(repl/refresh))
|
||||||
|
|
||||||
|
(defn reload-all
|
||||||
|
"Force reload all app namespaces."
|
||||||
|
[]
|
||||||
|
(stop)
|
||||||
|
(repl/refresh-all :after 'user/start))
|
||||||
|
|
||||||
|
(defn- clj-file? [_ {:keys [file]}]
|
||||||
|
(and file (.endsWith (.getName file) ".clj")))
|
||||||
|
|
||||||
|
(defn- on-file-change [_ _]
|
||||||
|
(log/info "File change detected, reloading namespaces...")
|
||||||
|
(log/info "Reloading workspaces...")
|
||||||
|
(try
|
||||||
|
(reset)
|
||||||
|
(log/info "Reload complete")
|
||||||
|
(catch Exception e
|
||||||
|
(log/error e "Reload failed"))))
|
||||||
|
|
||||||
|
(defn watch
|
||||||
|
"Start watching src directory for changes and auto-reload."
|
||||||
|
[]
|
||||||
|
(when @watcher
|
||||||
|
(hawk/stop! @watcher))
|
||||||
|
(reset! watcher
|
||||||
|
(hawk/watch! [{:paths ["src"]
|
||||||
|
:filter clj-file?
|
||||||
|
:handler on-file-change}]))
|
||||||
|
(log/info "File watcher started - will auto-reload on .clj changes"))
|
||||||
|
|
||||||
|
(defn unwatch
|
||||||
|
"Stop the file watcher."
|
||||||
|
[]
|
||||||
|
(when @watcher
|
||||||
|
(hawk/stop! @watcher)
|
||||||
|
(reset! watcher nil)
|
||||||
|
(log/info "File watcher stopped")))
|
||||||
|
|
||||||
|
(defn go
|
||||||
|
"Start the app and enable auto-reload on file changes."
|
||||||
|
[]
|
||||||
|
(start)
|
||||||
|
(watch)
|
||||||
|
:ready)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# My Haiku
|
||||||
|
|
||||||
|
Code finds its truth
|
||||||
|
In tests that catch the bugs
|
||||||
|
Software sleeps sound
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<configuration>
|
<configuration>
|
||||||
|
<!-- Console appender - INFO and above only -->
|
||||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||||
|
<level>INFO</level>
|
||||||
|
</filter>
|
||||||
<encoder>
|
<encoder>
|
||||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||||
</encoder>
|
</encoder>
|
||||||
</appender>
|
</appender>
|
||||||
|
|
||||||
<logger name="spiceflow" level="INFO"/>
|
<!-- File appender - DEBUG and above (captures all agent/user activity) -->
|
||||||
|
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>logs/spiceflow.log</file>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>logs/spiceflow.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||||
|
<maxHistory>7</maxHistory>
|
||||||
|
</rollingPolicy>
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- Spiceflow at DEBUG to capture all agent responses and user actions -->
|
||||||
|
<logger name="spiceflow" level="DEBUG"/>
|
||||||
<logger name="org.eclipse.jetty" level="WARN"/>
|
<logger name="org.eclipse.jetty" level="WARN"/>
|
||||||
|
|
||||||
<root level="INFO">
|
<root level="DEBUG">
|
||||||
<appender-ref ref="STDOUT"/>
|
<appender-ref ref="STDOUT"/>
|
||||||
|
<appender-ref ref="FILE"/>
|
||||||
</root>
|
</root>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
(ns spiceflow.adapters.opencode
|
(ns spiceflow.adapters.opencode
|
||||||
"Adapter for OpenCode CLI"
|
"Adapter for OpenCode CLI.
|
||||||
|
|
||||||
|
OpenCode works differently from Claude - it uses a single-shot execution model
|
||||||
|
where each message spawns a new `opencode run` process with the message as an argument."
|
||||||
(:require [spiceflow.adapters.protocol :as proto]
|
(:require [spiceflow.adapters.protocol :as proto]
|
||||||
[clojure.java.io :as io]
|
[clojure.java.io :as io]
|
||||||
[clojure.java.shell :as shell]
|
[clojure.java.shell :as shell]
|
||||||
@@ -60,26 +63,64 @@
|
|||||||
(log/warn "Failed to parse session export:" (.getMessage e))
|
(log/warn "Failed to parse session export:" (.getMessage e))
|
||||||
nil)))
|
nil)))
|
||||||
|
|
||||||
(defn- parse-stream-output
|
(defn- parse-json-event
|
||||||
"Parse a line of streaming output from OpenCode"
|
"Parse a JSON event from OpenCode's --format json output"
|
||||||
[line]
|
[line]
|
||||||
(try
|
(try
|
||||||
(when (and line (not (str/blank? line)))
|
(when (and line (not (str/blank? line)))
|
||||||
(if (str/starts-with? line "{")
|
|
||||||
(let [data (json/read-value line mapper)]
|
(let [data (json/read-value line mapper)]
|
||||||
(cond
|
(log/debug "OpenCode JSON event:" (:type data))
|
||||||
(:content data) {:event :content-delta
|
(case (:type data)
|
||||||
:text (:content data)}
|
;; Step start - beginning of a new step
|
||||||
(:done data) {:event :message-stop}
|
"step_start" {:event :init
|
||||||
(:error data) {:event :error
|
:session-id (:sessionID data)
|
||||||
:message (:error data)}
|
:cwd (get-in data [:part :cwd])}
|
||||||
:else {:raw data}))
|
|
||||||
;; Plain text output
|
;; Text content - the actual response text
|
||||||
{:event :content-delta
|
"text" {:event :content-delta
|
||||||
:text line}))
|
:text (get-in data [:part :text])}
|
||||||
|
|
||||||
|
;; Tool use events
|
||||||
|
"tool_start" {:event :tool-start
|
||||||
|
:tool (get-in data [:part :tool])
|
||||||
|
:input (get-in data [:part :input])}
|
||||||
|
"tool_finish" {:event :tool-result
|
||||||
|
:tool (get-in data [:part :tool])
|
||||||
|
:result (get-in data [:part :result])}
|
||||||
|
|
||||||
|
;; Step finish - end of current step
|
||||||
|
"step_finish" {:event :result
|
||||||
|
:session-id (:sessionID data)
|
||||||
|
:cost (get-in data [:part :cost])
|
||||||
|
:stop-reason (get-in data [:part :reason])}
|
||||||
|
|
||||||
|
;; Error events
|
||||||
|
"error" {:event :error
|
||||||
|
:message (:message data)}
|
||||||
|
|
||||||
|
;; Permission-related events (if OpenCode has them)
|
||||||
|
"permission_request" {:event :permission-request
|
||||||
|
:permission-request {:tools [(get-in data [:part :tool])]
|
||||||
|
:denials [{:tool (get-in data [:part :tool])
|
||||||
|
:input (get-in data [:part :input])
|
||||||
|
:description (get-in data [:part :description])}]}}
|
||||||
|
|
||||||
|
;; Default: pass through as raw (don't emit content-delta for unknown types)
|
||||||
|
(do
|
||||||
|
(log/debug "Unknown OpenCode event type:" (:type data))
|
||||||
|
{:raw data}))))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/debug "Failed to parse line:" line)
|
(log/debug "Failed to parse JSON line:" line (.getMessage e))
|
||||||
{:event :content-delta :text line})))
|
;; If it's not JSON, treat as plain text
|
||||||
|
(when (and line (not (str/blank? line)))
|
||||||
|
{:event :content-delta :text (str line "\n")}))))
|
||||||
|
|
||||||
|
(defn- parse-default-output
|
||||||
|
"Parse default (non-JSON) output from OpenCode"
|
||||||
|
[line]
|
||||||
|
(when (and line (not (str/blank? line)))
|
||||||
|
{:event :content-delta
|
||||||
|
:text (str line "\n")}))
|
||||||
|
|
||||||
(defrecord OpenCodeAdapter [command]
|
(defrecord OpenCodeAdapter [command]
|
||||||
proto/AgentAdapter
|
proto/AgentAdapter
|
||||||
@@ -92,44 +133,103 @@
|
|||||||
[]))
|
[]))
|
||||||
|
|
||||||
(spawn-session [_ session-id opts]
|
(spawn-session [_ session-id opts]
|
||||||
(let [{:keys [working-dir]} opts
|
;; For OpenCode, we don't start a process here - we just create a handle
|
||||||
args [command "run" "--session" session-id]
|
;; that stores the configuration. The actual process is started when send-message is called.
|
||||||
pb (ProcessBuilder. args)]
|
(let [{:keys [working-dir allowed-tools]} opts]
|
||||||
|
{:session-id session-id
|
||||||
|
:working-dir working-dir
|
||||||
|
:allowed-tools allowed-tools
|
||||||
|
:command command
|
||||||
|
;; These will be populated when send-message is called
|
||||||
|
:process nil
|
||||||
|
:stdout nil
|
||||||
|
:stderr nil}))
|
||||||
|
|
||||||
|
(send-message [_ handle message]
|
||||||
|
(try
|
||||||
|
(let [{:keys [session-id working-dir command allowed-tools]} handle
|
||||||
|
;; Build command args
|
||||||
|
;; Use --format json for structured output
|
||||||
|
;; Build the opencode command string
|
||||||
|
opencode-cmd (str command " run --format json"
|
||||||
|
(when session-id (str " --session " session-id))
|
||||||
|
" " (pr-str message))
|
||||||
|
;; Wrap with script -qc to create a pseudo-terminal
|
||||||
|
;; This forces Go to flush stdout properly (Go binaries ignore stdbuf)
|
||||||
|
args ["script" "-qc" opencode-cmd "/dev/null"]
|
||||||
|
_ (log/info "Starting OpenCode with args:" args)
|
||||||
|
pb (ProcessBuilder. (vec args))]
|
||||||
|
;; Set working directory
|
||||||
(when working-dir
|
(when working-dir
|
||||||
(.directory pb (io/file working-dir)))
|
(.directory pb (io/file working-dir)))
|
||||||
|
;; Don't merge stderr into stdout - keep them separate
|
||||||
(.redirectErrorStream pb false)
|
(.redirectErrorStream pb false)
|
||||||
(let [process (.start pb)]
|
|
||||||
{:process process
|
|
||||||
:stdin (BufferedWriter. (OutputStreamWriter. (.getOutputStream process) StandardCharsets/UTF_8))
|
|
||||||
:stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
|
|
||||||
:stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))})))
|
|
||||||
|
|
||||||
(send-message [_ {:keys [stdin]} message]
|
;; Start the process
|
||||||
(try
|
(let [process (.start pb)
|
||||||
(.write stdin message)
|
stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8))
|
||||||
(.newLine stdin)
|
stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))]
|
||||||
(.flush stdin)
|
;; Update the handle with the running process
|
||||||
true
|
;; Note: We're mutating the handle here by storing process info
|
||||||
|
;; The caller should use the returned handle
|
||||||
|
(assoc handle
|
||||||
|
:process process
|
||||||
|
:stdout stdout
|
||||||
|
:stderr stderr)))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/error "Failed to send message:" (.getMessage e))
|
(log/error "Failed to start OpenCode process:" (.getMessage e))
|
||||||
false)))
|
nil)))
|
||||||
|
|
||||||
(read-stream [this {:keys [stdout]} callback]
|
(read-stream [this {:keys [stdout stderr process]} callback]
|
||||||
|
(log/info "read-stream starting, stdout:" (boolean stdout) "process:" (boolean process) "process-alive:" (when process (.isAlive process)))
|
||||||
|
(try
|
||||||
|
;; Start a thread to log stderr
|
||||||
|
(when stderr
|
||||||
|
(future
|
||||||
(try
|
(try
|
||||||
(loop []
|
(loop []
|
||||||
(when-let [line (.readLine stdout)]
|
(when-let [line (.readLine stderr)]
|
||||||
(when-let [parsed (proto/parse-output this line)]
|
(log/info "[OpenCode stderr]" line)
|
||||||
(callback parsed))
|
|
||||||
(recur)))
|
(recur)))
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
(log/debug "Stream ended:" (.getMessage e)))))
|
(log/info "Stderr stream ended:" (.getMessage e))))))
|
||||||
|
|
||||||
|
;; Read stdout for JSON events
|
||||||
|
(log/info "Starting stdout read loop")
|
||||||
|
(loop []
|
||||||
|
(log/debug "Waiting for line from stdout...")
|
||||||
|
(when-let [line (.readLine stdout)]
|
||||||
|
(log/info "[OpenCode stdout]" line)
|
||||||
|
(let [parsed (proto/parse-output this line)]
|
||||||
|
(when parsed
|
||||||
|
(log/info "Parsed event:" (:event parsed))
|
||||||
|
(callback parsed))
|
||||||
|
;; Continue reading unless we hit a terminal event
|
||||||
|
;; Note: step_finish with reason "tool-calls" is NOT terminal - OpenCode
|
||||||
|
;; continues after running tools. Only stop on :error or :result with
|
||||||
|
;; a non-tool-calls reason.
|
||||||
|
(if (or (= :error (:event parsed))
|
||||||
|
(and (= :result (:event parsed))
|
||||||
|
(not= "tool-calls" (:stop-reason parsed))))
|
||||||
|
(log/info "Received terminal event, stopping stream read. stop-reason:" (:stop-reason parsed))
|
||||||
|
(recur)))))
|
||||||
|
(log/info "stdout read loop ended (nil line)")
|
||||||
|
|
||||||
|
;; Wait for process to complete
|
||||||
|
(when process
|
||||||
|
(log/info "Waiting for process to complete")
|
||||||
|
(.waitFor process)
|
||||||
|
(log/info "Process completed with exit code:" (.exitValue process)))
|
||||||
|
|
||||||
|
(catch Exception e
|
||||||
|
(log/error "Stream error:" (.getMessage e) (class e)))))
|
||||||
|
|
||||||
(kill-process [_ {:keys [process]}]
|
(kill-process [_ {:keys [process]}]
|
||||||
(when process
|
(when process
|
||||||
(.destroyForcibly process)))
|
(.destroyForcibly process)))
|
||||||
|
|
||||||
(parse-output [_ line]
|
(parse-output [_ line]
|
||||||
(parse-stream-output line)))
|
(parse-json-event line)))
|
||||||
|
|
||||||
(defn create-adapter
|
(defn create-adapter
|
||||||
"Create an OpenCode adapter"
|
"Create an OpenCode adapter"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
[store]
|
[store]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [body (:body request)]
|
(let [body (:body request)]
|
||||||
|
(log/debug "API request: create-session" {:body body})
|
||||||
(if (db/valid-session? body)
|
(if (db/valid-session? body)
|
||||||
(let [session (db/save-session store body)]
|
(let [session (db/save-session store body)]
|
||||||
(-> (json-response session)
|
(-> (json-response session)
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
[store]
|
[store]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [id (get-in request [:path-params :id])]
|
(let [id (get-in request [:path-params :id])]
|
||||||
|
(log/debug "API request: delete-session" {:session-id id})
|
||||||
(if (db/get-session store id)
|
(if (db/get-session store id)
|
||||||
(do
|
(do
|
||||||
(manager/stop-session store id)
|
(manager/stop-session store id)
|
||||||
@@ -63,8 +65,9 @@
|
|||||||
(fn [request]
|
(fn [request]
|
||||||
(let [id (get-in request [:path-params :id])
|
(let [id (get-in request [:path-params :id])
|
||||||
body (:body request)]
|
body (:body request)]
|
||||||
|
(log/debug "API request: update-session" {:session-id id :body body})
|
||||||
(if (db/get-session store id)
|
(if (db/get-session store id)
|
||||||
(let [updated (db/update-session store id (select-keys body [:title]))]
|
(let [updated (db/update-session store id (select-keys body [:title :auto-accept-edits]))]
|
||||||
(json-response updated))
|
(json-response updated))
|
||||||
(error-response 404 "Session not found")))))
|
(error-response 404 "Session not found")))))
|
||||||
|
|
||||||
@@ -73,6 +76,7 @@
|
|||||||
(fn [request]
|
(fn [request]
|
||||||
(let [id (get-in request [:path-params :id])
|
(let [id (get-in request [:path-params :id])
|
||||||
message (get-in request [:body :message])]
|
message (get-in request [:body :message])]
|
||||||
|
(log/debug "API request: send-message" {:session-id id :message message})
|
||||||
(if-let [session (db/get-session store id)]
|
(if-let [session (db/get-session store id)]
|
||||||
(try
|
(try
|
||||||
;; Send message and start streaming in a separate thread
|
;; Send message and start streaming in a separate thread
|
||||||
@@ -101,6 +105,7 @@
|
|||||||
(let [id (get-in request [:path-params :id])
|
(let [id (get-in request [:path-params :id])
|
||||||
{:keys [response message]} (:body request)
|
{:keys [response message]} (:body request)
|
||||||
response-type (keyword response)]
|
response-type (keyword response)]
|
||||||
|
(log/debug "API request: permission-response" {:session-id id :response response :message message})
|
||||||
(if-let [session (db/get-session store id)]
|
(if-let [session (db/get-session store id)]
|
||||||
(if (#{:accept :deny :steer} response-type)
|
(if (#{:accept :deny :steer} response-type)
|
||||||
(try
|
(try
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
(def ^:private mapper (json/object-mapper {:encode-key-fn name
|
(def ^:private mapper (json/object-mapper {:encode-key-fn name
|
||||||
:decode-key-fn keyword}))
|
:decode-key-fn keyword}))
|
||||||
|
|
||||||
|
;; Function to get pending permission for a session (set by core.clj)
|
||||||
|
(defonce ^:private pending-permission-fn (atom nil))
|
||||||
|
|
||||||
|
(defn set-pending-permission-fn!
|
||||||
|
"Set the function to retrieve pending permissions for a session"
|
||||||
|
[f]
|
||||||
|
(reset! pending-permission-fn f))
|
||||||
|
|
||||||
;; Connected WebSocket sessions: session-id -> #{sockets}
|
;; Connected WebSocket sessions: session-id -> #{sockets}
|
||||||
(defonce ^:private connections (ConcurrentHashMap.))
|
(defonce ^:private connections (ConcurrentHashMap.))
|
||||||
|
|
||||||
@@ -27,7 +35,8 @@
|
|||||||
(defn broadcast-to-session
|
(defn broadcast-to-session
|
||||||
"Broadcast an event to all WebSocket connections subscribed to a session"
|
"Broadcast an event to all WebSocket connections subscribed to a session"
|
||||||
[session-id event]
|
[session-id event]
|
||||||
(log/debug "Broadcasting to session:" session-id "event:" event)
|
(log/debug "Broadcasting to session:" session-id "event-type:" (:event event))
|
||||||
|
(log/debug "Broadcast full event data:" (pr-str event))
|
||||||
(when-let [subscribers (.get connections session-id)]
|
(when-let [subscribers (.get connections session-id)]
|
||||||
(let [message (assoc event :session-id session-id)]
|
(let [message (assoc event :session-id session-id)]
|
||||||
(doseq [socket subscribers]
|
(doseq [socket subscribers]
|
||||||
@@ -47,7 +56,14 @@
|
|||||||
(let [new-set (ConcurrentHashMap/newKeySet)]
|
(let [new-set (ConcurrentHashMap/newKeySet)]
|
||||||
(.putIfAbsent connections session-id new-set)
|
(.putIfAbsent connections session-id new-set)
|
||||||
(or (.get connections session-id) new-set)))]
|
(or (.get connections session-id) new-set)))]
|
||||||
(.add subscribers socket)))
|
(.add subscribers socket)
|
||||||
|
;; Send any pending permission request immediately after subscribing
|
||||||
|
(when-let [get-pending @pending-permission-fn]
|
||||||
|
(when-let [pending (get-pending session-id)]
|
||||||
|
(log/debug "Sending pending permission to newly subscribed client:" pending)
|
||||||
|
(send-to-ws socket {:event :permission-request
|
||||||
|
:permission-request pending
|
||||||
|
:session-id session-id})))))
|
||||||
|
|
||||||
(defn- unsubscribe-from-session
|
(defn- unsubscribe-from-session
|
||||||
"Unsubscribe a WebSocket socket from a session"
|
"Unsubscribe a WebSocket socket from a session"
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
(defstate server
|
(defstate server
|
||||||
:start (let [port (get-in config/config [:server :port] 3000)
|
:start (let [port (get-in config/config [:server :port] 3000)
|
||||||
host (get-in config/config [:server :host] "0.0.0.0")
|
host (get-in config/config [:server :host] "0.0.0.0")
|
||||||
|
;; Wire up pending permission function for WebSocket (partially apply store)
|
||||||
|
_ (ws/set-pending-permission-fn! (partial manager/get-pending-permission store))
|
||||||
api-app (routes/create-app store ws/broadcast-to-session)
|
api-app (routes/create-app store ws/broadcast-to-session)
|
||||||
;; Wrap the app to handle WebSocket upgrades on /api/ws
|
;; Wrap the app to handle WebSocket upgrades on /api/ws
|
||||||
app (fn [request]
|
app (fn [request]
|
||||||
|
|||||||
@@ -65,6 +65,10 @@
|
|||||||
new-message))
|
new-message))
|
||||||
|
|
||||||
(get-message [_ id]
|
(get-message [_ id]
|
||||||
|
(get @messages id))
|
||||||
|
|
||||||
|
(update-message [_ id data]
|
||||||
|
(swap! messages update id merge data)
|
||||||
(get @messages id)))
|
(get @messages id)))
|
||||||
|
|
||||||
(defn create-store
|
(defn create-store
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
(save-message [this message]
|
(save-message [this message]
|
||||||
"Save a new message. Returns the saved message with ID.")
|
"Save a new message. Returns the saved message with ID.")
|
||||||
(get-message [this id]
|
(get-message [this id]
|
||||||
"Get a single message by ID"))
|
"Get a single message by ID")
|
||||||
|
(update-message [this id data]
|
||||||
|
"Update message fields. Returns updated message."))
|
||||||
|
|
||||||
(defn valid-session?
|
(defn valid-session?
|
||||||
"Validate session data has required fields"
|
"Validate session data has required fields"
|
||||||
|
|||||||
@@ -16,6 +16,17 @@
|
|||||||
(defn- now-iso []
|
(defn- now-iso []
|
||||||
(.toString (Instant/now)))
|
(.toString (Instant/now)))
|
||||||
|
|
||||||
|
;; Status ID constants for foreign key references
|
||||||
|
(def status-ids
|
||||||
|
{:idle 1
|
||||||
|
:processing 2
|
||||||
|
:awaiting-permission 3})
|
||||||
|
|
||||||
|
(def status-names
|
||||||
|
{1 :idle
|
||||||
|
2 :processing
|
||||||
|
3 :awaiting-permission})
|
||||||
|
|
||||||
(defn- row->session
|
(defn- row->session
|
||||||
"Convert a database row to a session map"
|
"Convert a database row to a session map"
|
||||||
[row]
|
[row]
|
||||||
@@ -43,14 +54,24 @@
|
|||||||
|
|
||||||
(defn- session->row
|
(defn- session->row
|
||||||
"Convert a session map to database columns"
|
"Convert a session map to database columns"
|
||||||
[{:keys [id provider external-id title working-dir status]}]
|
[{:keys [id provider external-id title working-dir spawn-dir status pending-permission auto-accept-edits]}]
|
||||||
(cond-> {}
|
(cond-> {}
|
||||||
id (assoc :id id)
|
id (assoc :id id)
|
||||||
provider (assoc :provider (name provider))
|
provider (assoc :provider (name provider))
|
||||||
external-id (assoc :external_id external-id)
|
external-id (assoc :external_id external-id)
|
||||||
title (assoc :title title)
|
title (assoc :title title)
|
||||||
working-dir (assoc :working_dir working-dir)
|
working-dir (assoc :working_dir working-dir)
|
||||||
status (assoc :status (name status))))
|
spawn-dir (assoc :spawn_dir spawn-dir)
|
||||||
|
status (assoc :status_id (get status-ids (keyword status) 1))
|
||||||
|
;; Handle pending-permission: can be a map (to serialize) or :clear (to set null)
|
||||||
|
(some? pending-permission)
|
||||||
|
(assoc :pending_permission
|
||||||
|
(if (= :clear pending-permission)
|
||||||
|
nil
|
||||||
|
(json/write-value-as-string pending-permission)))
|
||||||
|
;; Handle auto-accept-edits as integer (0 or 1)
|
||||||
|
(some? auto-accept-edits)
|
||||||
|
(assoc :auto_accept_edits (if auto-accept-edits 1 0))))
|
||||||
|
|
||||||
(defn- message->row
|
(defn- message->row
|
||||||
"Convert a message map to database columns"
|
"Convert a message map to database columns"
|
||||||
@@ -70,14 +91,18 @@
|
|||||||
["SELECT * FROM sessions ORDER BY updated_at DESC"]
|
["SELECT * FROM sessions ORDER BY updated_at DESC"]
|
||||||
{:builder-fn rs/as-unqualified-kebab-maps})]
|
{:builder-fn rs/as-unqualified-kebab-maps})]
|
||||||
(mapv (fn [row]
|
(mapv (fn [row]
|
||||||
{:id (:id row)
|
(cond-> {:id (:id row)
|
||||||
:provider (keyword (:provider row))
|
:provider (keyword (:provider row))
|
||||||
:external-id (:external-id row)
|
:external-id (:external-id row)
|
||||||
:title (:title row)
|
:title (:title row)
|
||||||
:working-dir (:working-dir row)
|
:working-dir (:working-dir row)
|
||||||
:status (keyword (or (:status row) "idle"))
|
:spawn-dir (:spawn-dir row)
|
||||||
|
:status (get status-names (:status-id row) :idle)
|
||||||
|
:auto-accept-edits (= 1 (:auto-accept-edits row))
|
||||||
:created-at (:created-at row)
|
:created-at (:created-at row)
|
||||||
:updated-at (:updated-at row)})
|
:updated-at (:updated-at row)}
|
||||||
|
(:pending-permission row)
|
||||||
|
(assoc :pending-permission (json/read-value (:pending-permission row) mapper))))
|
||||||
rows)))
|
rows)))
|
||||||
|
|
||||||
(get-session [_ id]
|
(get-session [_ id]
|
||||||
@@ -85,14 +110,18 @@
|
|||||||
["SELECT * FROM sessions WHERE id = ?" id]
|
["SELECT * FROM sessions WHERE id = ?" id]
|
||||||
{:builder-fn rs/as-unqualified-kebab-maps})]
|
{:builder-fn rs/as-unqualified-kebab-maps})]
|
||||||
(when row
|
(when row
|
||||||
{:id (:id row)
|
(cond-> {:id (:id row)
|
||||||
:provider (keyword (:provider row))
|
:provider (keyword (:provider row))
|
||||||
:external-id (:external-id row)
|
:external-id (:external-id row)
|
||||||
:title (:title row)
|
:title (:title row)
|
||||||
:working-dir (:working-dir row)
|
:working-dir (:working-dir row)
|
||||||
:status (keyword (or (:status row) "idle"))
|
:spawn-dir (:spawn-dir row)
|
||||||
|
:status (get status-names (:status-id row) :idle)
|
||||||
|
:auto-accept-edits (= 1 (:auto-accept-edits row))
|
||||||
:created-at (:created-at row)
|
:created-at (:created-at row)
|
||||||
:updated-at (:updated-at row)})))
|
:updated-at (:updated-at row)}
|
||||||
|
(:pending-permission row)
|
||||||
|
(assoc :pending-permission (json/read-value (:pending-permission row) mapper))))))
|
||||||
|
|
||||||
(save-session [this session]
|
(save-session [this session]
|
||||||
(let [id (or (:id session) (generate-id))
|
(let [id (or (:id session) (generate-id))
|
||||||
@@ -154,17 +183,28 @@
|
|||||||
:content (:content row)
|
:content (:content row)
|
||||||
:metadata (when-let [m (:metadata row)]
|
:metadata (when-let [m (:metadata row)]
|
||||||
(json/read-value m mapper))
|
(json/read-value m mapper))
|
||||||
:created-at (:created-at row)}))))
|
:created-at (:created-at row)})))
|
||||||
|
|
||||||
|
(update-message [this id data]
|
||||||
|
(let [row (message->row data)]
|
||||||
|
(sql/update! datasource :messages row {:id id})
|
||||||
|
(proto/get-message this id))))
|
||||||
|
|
||||||
(def schema
|
(def schema
|
||||||
"SQLite schema for spiceflow"
|
"SQLite schema for spiceflow"
|
||||||
["CREATE TABLE IF NOT EXISTS sessions (
|
["CREATE TABLE IF NOT EXISTS session_statuses (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE
|
||||||
|
)"
|
||||||
|
"CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
provider TEXT NOT NULL,
|
provider TEXT NOT NULL,
|
||||||
external_id TEXT,
|
external_id TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
working_dir TEXT,
|
working_dir TEXT,
|
||||||
status TEXT DEFAULT 'idle',
|
spawn_dir TEXT,
|
||||||
|
status_id INTEGER DEFAULT 1 REFERENCES session_statuses(id),
|
||||||
|
pending_permission TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
)"
|
)"
|
||||||
@@ -178,13 +218,70 @@
|
|||||||
)"
|
)"
|
||||||
"CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)"
|
"CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)"
|
||||||
"CREATE INDEX IF NOT EXISTS idx_sessions_provider ON sessions(provider)"
|
"CREATE INDEX IF NOT EXISTS idx_sessions_provider ON sessions(provider)"
|
||||||
"CREATE INDEX IF NOT EXISTS idx_sessions_external_id ON sessions(external_id)"])
|
"CREATE INDEX IF NOT EXISTS idx_sessions_external_id ON sessions(external_id)"
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sessions_status_id ON sessions(status_id)"])
|
||||||
|
|
||||||
|
(def migrations
|
||||||
|
"Database migrations for existing databases"
|
||||||
|
[;; Add pending_permission column if it doesn't exist
|
||||||
|
"ALTER TABLE sessions ADD COLUMN pending_permission TEXT"
|
||||||
|
;; Add status_id column if it doesn't exist (for migration from TEXT status)
|
||||||
|
"ALTER TABLE sessions ADD COLUMN status_id INTEGER DEFAULT 1 REFERENCES session_statuses(id)"
|
||||||
|
;; Add spawn_dir column - the original directory used when spawning Claude
|
||||||
|
;; This is separate from working_dir which tracks current directory
|
||||||
|
"ALTER TABLE sessions ADD COLUMN spawn_dir TEXT"
|
||||||
|
;; Add auto_accept_edits column - when enabled, pre-grants Write/Edit tool permissions
|
||||||
|
"ALTER TABLE sessions ADD COLUMN auto_accept_edits INTEGER DEFAULT 0"])
|
||||||
|
|
||||||
|
(defn- seed-statuses!
|
||||||
|
"Seed the session_statuses table with initial values"
|
||||||
|
[datasource]
|
||||||
|
(doseq [[status-name status-id] status-ids]
|
||||||
|
(try
|
||||||
|
(jdbc/execute! datasource
|
||||||
|
["INSERT OR IGNORE INTO session_statuses (id, name) VALUES (?, ?)"
|
||||||
|
status-id (name status-name)])
|
||||||
|
(catch Exception _
|
||||||
|
;; Ignore - already exists
|
||||||
|
nil))))
|
||||||
|
|
||||||
|
(defn- migrate-status-column!
|
||||||
|
"Migrate existing TEXT status values to status_id foreign key"
|
||||||
|
[datasource]
|
||||||
|
;; Update status_id based on existing status TEXT column
|
||||||
|
(try
|
||||||
|
(jdbc/execute! datasource
|
||||||
|
["UPDATE sessions SET status_id = CASE status
|
||||||
|
WHEN 'idle' THEN 1
|
||||||
|
WHEN 'running' THEN 2
|
||||||
|
WHEN 'processing' THEN 2
|
||||||
|
WHEN 'awaiting-permission' THEN 3
|
||||||
|
WHEN 'awaiting_permission' THEN 3
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
WHERE status_id IS NULL OR status IS NOT NULL"])
|
||||||
|
(catch Exception _
|
||||||
|
;; Ignore - status column may not exist or already migrated
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
(defn- run-migrations!
|
||||||
|
"Run migrations, ignoring errors for already-applied migrations"
|
||||||
|
[datasource]
|
||||||
|
(doseq [stmt migrations]
|
||||||
|
(try
|
||||||
|
(jdbc/execute! datasource [stmt])
|
||||||
|
(catch Exception _
|
||||||
|
;; Ignore - migration likely already applied
|
||||||
|
nil))))
|
||||||
|
|
||||||
(defn init-schema!
|
(defn init-schema!
|
||||||
"Initialize database schema"
|
"Initialize database schema"
|
||||||
[datasource]
|
[datasource]
|
||||||
(doseq [stmt schema]
|
(doseq [stmt schema]
|
||||||
(jdbc/execute! datasource [stmt])))
|
(jdbc/execute! datasource [stmt]))
|
||||||
|
(seed-statuses! datasource)
|
||||||
|
(run-migrations! datasource)
|
||||||
|
(migrate-status-column! datasource))
|
||||||
|
|
||||||
(defn create-store
|
(defn create-store
|
||||||
"Create a SQLite store with the given database path"
|
"Create a SQLite store with the given database path"
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
;; Active process handles for running sessions
|
;; Active process handles for running sessions
|
||||||
(defonce ^:private active-processes (ConcurrentHashMap.))
|
(defonce ^:private active-processes (ConcurrentHashMap.))
|
||||||
|
|
||||||
;; Pending permission requests: session-id -> {:tools [...] :denials [...]}
|
|
||||||
(defonce ^:private pending-permissions (ConcurrentHashMap.))
|
|
||||||
|
|
||||||
(defn get-adapter
|
(defn get-adapter
|
||||||
"Get the appropriate adapter for a provider"
|
"Get the appropriate adapter for a provider"
|
||||||
[provider]
|
[provider]
|
||||||
@@ -35,22 +32,33 @@
|
|||||||
(defn start-session
|
(defn start-session
|
||||||
"Start a CLI process for a session"
|
"Start a CLI process for a session"
|
||||||
[store session-id]
|
[store session-id]
|
||||||
|
(log/debug "User action: start-session" {:session-id session-id})
|
||||||
(let [session (db/get-session store session-id)]
|
(let [session (db/get-session store session-id)]
|
||||||
(when-not session
|
(when-not session
|
||||||
(throw (ex-info "Session not found" {:session-id session-id})))
|
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||||
(when (session-running? session-id)
|
(when (session-running? session-id)
|
||||||
(throw (ex-info "Session already running" {:session-id session-id})))
|
(throw (ex-info "Session already running" {:session-id session-id})))
|
||||||
|
;; Use spawn-dir for spawning (this is the original directory the session was created in)
|
||||||
|
;; Fall back to working-dir for existing sessions that don't have spawn-dir yet
|
||||||
(let [adapter (get-adapter (:provider session))
|
(let [adapter (get-adapter (:provider session))
|
||||||
|
spawn-dir (or (:spawn-dir session) (:working-dir session))
|
||||||
|
;; Pre-grant Write/Edit tools if auto-accept-edits is enabled
|
||||||
|
allowed-tools (when (:auto-accept-edits session)
|
||||||
|
["Write" "Edit"])
|
||||||
|
_ (log/debug "Starting session with spawn-dir:" spawn-dir "external-id:" (:external-id session) "allowed-tools:" allowed-tools)
|
||||||
handle (adapter/spawn-session adapter
|
handle (adapter/spawn-session adapter
|
||||||
(:external-id session)
|
(:external-id session)
|
||||||
{:working-dir (:working-dir session)})]
|
(cond-> {:working-dir spawn-dir}
|
||||||
|
(seq allowed-tools)
|
||||||
|
(assoc :allowed-tools allowed-tools)))]
|
||||||
(.put active-processes session-id handle)
|
(.put active-processes session-id handle)
|
||||||
(db/update-session store session-id {:status :running})
|
(db/update-session store session-id {:status :processing})
|
||||||
handle)))
|
handle)))
|
||||||
|
|
||||||
(defn stop-session
|
(defn stop-session
|
||||||
"Stop a running CLI process for a session"
|
"Stop a running CLI process for a session"
|
||||||
[store session-id]
|
[store session-id]
|
||||||
|
(log/debug "User action: stop-session" {:session-id session-id})
|
||||||
(when-let [handle (.remove active-processes session-id)]
|
(when-let [handle (.remove active-processes session-id)]
|
||||||
(let [session (db/get-session store session-id)
|
(let [session (db/get-session store session-id)
|
||||||
adapter (get-adapter (:provider session))]
|
adapter (get-adapter (:provider session))]
|
||||||
@@ -60,6 +68,7 @@
|
|||||||
(defn send-message-to-session
|
(defn send-message-to-session
|
||||||
"Send a message to a running session"
|
"Send a message to a running session"
|
||||||
[store session-id message]
|
[store session-id message]
|
||||||
|
(log/debug "User action: send-message" {:session-id session-id :message message})
|
||||||
(let [session (db/get-session store session-id)
|
(let [session (db/get-session store session-id)
|
||||||
_ (when-not session
|
_ (when-not session
|
||||||
(throw (ex-info "Session not found" {:session-id session-id})))
|
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||||
@@ -71,40 +80,88 @@
|
|||||||
(db/save-message store {:session-id session-id
|
(db/save-message store {:session-id session-id
|
||||||
:role :user
|
:role :user
|
||||||
:content message})
|
:content message})
|
||||||
;; Send to CLI
|
;; Send to CLI - for OpenCode, this returns an updated handle with the process
|
||||||
(adapter/send-message adapter handle message)))
|
(let [result (adapter/send-message adapter handle message)]
|
||||||
|
(log/info "send-message result type:" (type result) "has-process:" (boolean (:process result)))
|
||||||
|
;; If result is a map with :process, it's an updated handle (OpenCode)
|
||||||
|
;; Store it so stream-session-response can use it
|
||||||
|
(when (and (map? result) (:process result))
|
||||||
|
(log/info "Storing updated handle with process for session:" session-id)
|
||||||
|
(.put active-processes session-id result))
|
||||||
|
result)))
|
||||||
|
|
||||||
;; Permission handling - defined before stream-session-response which uses them
|
;; Permission handling - persisted to database
|
||||||
|
|
||||||
(defn set-pending-permission
|
(defn set-pending-permission
|
||||||
"Store a pending permission request for a session"
|
"Store a pending permission request for a session in the database"
|
||||||
[session-id permission-request]
|
[store session-id permission-request]
|
||||||
(.put pending-permissions session-id permission-request))
|
(db/update-session store session-id {:pending-permission permission-request}))
|
||||||
|
|
||||||
(defn get-pending-permission
|
(defn get-pending-permission
|
||||||
"Get pending permission request for a session"
|
"Get pending permission request for a session from the database"
|
||||||
[session-id]
|
[store session-id]
|
||||||
(.get pending-permissions session-id))
|
(:pending-permission (db/get-session store session-id)))
|
||||||
|
|
||||||
(defn clear-pending-permission
|
(defn clear-pending-permission
|
||||||
"Clear pending permission for a session"
|
"Clear pending permission for a session in the database"
|
||||||
[session-id]
|
[store session-id]
|
||||||
(.remove pending-permissions session-id))
|
(db/update-session store session-id {:pending-permission :clear}))
|
||||||
|
|
||||||
|
(defn- extract-path-from-string
|
||||||
|
"Extract a Unix path from a string, looking for patterns like /home/user/dir"
|
||||||
|
[s]
|
||||||
|
(when (string? s)
|
||||||
|
;; Look for absolute paths - match /something/something pattern
|
||||||
|
;; Paths typically don't contain spaces, newlines, or common sentence punctuation
|
||||||
|
(let [path-pattern #"(/(?:home|root|usr|var|tmp|opt|etc|mnt|media|Users)[^\s\n\r\"'<>|:;,!?\[\]{}()]*)"
|
||||||
|
;; Also match generic absolute paths that look like directories
|
||||||
|
generic-pattern #"^(/[^\s\n\r\"'<>|:;,!?\[\]{}()]+)$"]
|
||||||
|
(or
|
||||||
|
;; First try to find a path that looks like a home/project directory
|
||||||
|
(when-let [match (re-find path-pattern s)]
|
||||||
|
(if (vector? match) (first match) match))
|
||||||
|
;; If the entire trimmed string is a single absolute path, use it
|
||||||
|
(when-let [match (re-find generic-pattern (clojure.string/trim s))]
|
||||||
|
(if (vector? match) (second match) match))))))
|
||||||
|
|
||||||
|
(defn- extract-working-dir-from-tool-result
|
||||||
|
"Extract working directory from tool result if it looks like a path.
|
||||||
|
Tool results from pwd, cd, or bash commands may contain directory paths."
|
||||||
|
[content]
|
||||||
|
(when (and (vector? content) (seq content))
|
||||||
|
;; Look through tool results for path-like content
|
||||||
|
(->> content
|
||||||
|
(filter #(= "tool_result" (:type %)))
|
||||||
|
(map :content)
|
||||||
|
(filter string?)
|
||||||
|
;; Check each line of the output for a path
|
||||||
|
(mapcat clojure.string/split-lines)
|
||||||
|
(map clojure.string/trim)
|
||||||
|
(keep extract-path-from-string)
|
||||||
|
;; Prefer longer paths (more specific), take the last one found
|
||||||
|
(sort-by count)
|
||||||
|
last)))
|
||||||
|
|
||||||
(defn stream-session-response
|
(defn stream-session-response
|
||||||
"Stream response from a running session, calling callback for each event"
|
"Stream response from a running session, calling callback for each event"
|
||||||
[store session-id callback]
|
[store session-id callback]
|
||||||
|
(log/info "stream-session-response starting for session:" session-id)
|
||||||
(let [session (db/get-session store session-id)
|
(let [session (db/get-session store session-id)
|
||||||
_ (when-not session
|
_ (when-not session
|
||||||
(throw (ex-info "Session not found" {:session-id session-id})))
|
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||||
handle (get-active-process session-id)
|
handle (get-active-process session-id)
|
||||||
|
_ (log/info "Got handle for session:" session-id "has-process:" (boolean (:process handle)) "has-stdout:" (boolean (:stdout handle)))
|
||||||
_ (when-not handle
|
_ (when-not handle
|
||||||
(throw (ex-info "Session not running" {:session-id session-id})))
|
(throw (ex-info "Session not running" {:session-id session-id})))
|
||||||
adapter (get-adapter (:provider session))
|
adapter (get-adapter (:provider session))
|
||||||
content-buffer (StringBuilder.)]
|
content-buffer (StringBuilder.)
|
||||||
|
last-working-dir (atom nil)]
|
||||||
;; Read stream and accumulate content
|
;; Read stream and accumulate content
|
||||||
|
(log/info "Starting to read stream for session:" session-id)
|
||||||
(adapter/read-stream adapter handle
|
(adapter/read-stream adapter handle
|
||||||
(fn [event]
|
(fn [event]
|
||||||
|
(log/info "Received event:" (:event event) "text:" (when (:text event) (subs (str (:text event)) 0 (min 50 (count (str (:text event)))))))
|
||||||
|
(log/debug "Agent response full event:" (pr-str event))
|
||||||
(callback event)
|
(callback event)
|
||||||
;; Accumulate text content
|
;; Accumulate text content
|
||||||
(when-let [text (:text event)]
|
(when-let [text (:text event)]
|
||||||
@@ -115,12 +172,24 @@
|
|||||||
(not (:external-id session)))
|
(not (:external-id session)))
|
||||||
(log/debug "Capturing external session-id:" (:session-id event))
|
(log/debug "Capturing external session-id:" (:session-id event))
|
||||||
(db/update-session store session-id {:external-id (:session-id event)}))
|
(db/update-session store session-id {:external-id (:session-id event)}))
|
||||||
;; Also capture working directory from cwd
|
;; Capture spawn-dir from init cwd (only for new sessions that don't have it)
|
||||||
(when (and (:cwd event)
|
;; This is the directory where Claude's project is, used for resuming
|
||||||
(or (nil? (:working-dir session))
|
(when (and (:cwd event) (not (:spawn-dir session)))
|
||||||
(empty? (:working-dir session))))
|
(log/debug "Capturing spawn-dir from init:" (:cwd event))
|
||||||
(log/debug "Capturing working directory:" (:cwd event))
|
(db/update-session store session-id {:spawn-dir (:cwd event)}))
|
||||||
(db/update-session store session-id {:working-dir (:cwd event)})))
|
;; Also set working directory from init cwd
|
||||||
|
(when (:cwd event)
|
||||||
|
(log/debug "Capturing working directory from init:" (:cwd event))
|
||||||
|
(reset! last-working-dir (:cwd event))))
|
||||||
|
;; Track working directory from tool results (e.g., after cd && pwd)
|
||||||
|
(when (and (= :user (:role event))
|
||||||
|
(vector? (:content event)))
|
||||||
|
(when-let [dir (extract-working-dir-from-tool-result (:content event))]
|
||||||
|
(log/info "Detected working directory from tool result:" dir)
|
||||||
|
(reset! last-working-dir dir)
|
||||||
|
;; Emit working-dir-update event so UI can update in real-time
|
||||||
|
(callback {:event :working-dir-update
|
||||||
|
:working-dir dir})))
|
||||||
;; Also capture external-id from result event if not already set
|
;; Also capture external-id from result event if not already set
|
||||||
(when (and (= :result (:event event))
|
(when (and (= :result (:event event))
|
||||||
(:session-id event)
|
(:session-id event)
|
||||||
@@ -129,21 +198,52 @@
|
|||||||
(db/update-session store session-id {:external-id (:session-id event)}))
|
(db/update-session store session-id {:external-id (:session-id event)}))
|
||||||
;; On result event, check for permission requests
|
;; On result event, check for permission requests
|
||||||
(when (= :result (:event event))
|
(when (= :result (:event event))
|
||||||
(let [content (.toString content-buffer)]
|
;; Use accumulated content, or fall back to result content
|
||||||
;; Save accumulated message if any
|
;; (resumed sessions may not stream content-delta events)
|
||||||
|
(let [accumulated (.toString content-buffer)
|
||||||
|
result-content (:content event)
|
||||||
|
content (if (seq accumulated)
|
||||||
|
accumulated
|
||||||
|
result-content)]
|
||||||
|
;; If no streaming content but result has content, emit it for the client
|
||||||
|
(when (and (empty? accumulated) (seq result-content))
|
||||||
|
(callback {:event :content-delta :text result-content}))
|
||||||
|
;; Save message if any content
|
||||||
(when (seq content)
|
(when (seq content)
|
||||||
(db/save-message store {:session-id session-id
|
(db/save-message store {:session-id session-id
|
||||||
:role :assistant
|
:role :assistant
|
||||||
:content content}))
|
:content content}))
|
||||||
;; If there's a permission request, store it and emit event
|
;; If there's a permission request, save it as a message and emit event
|
||||||
(when-let [perm-req (:permission-request event)]
|
(when-let [perm-req (:permission-request event)]
|
||||||
(log/info "Permission request detected:" perm-req)
|
(log/info "Permission request detected:" perm-req)
|
||||||
(set-pending-permission session-id perm-req)
|
;; Build description for the permission message content
|
||||||
|
(let [description (->> (:denials perm-req)
|
||||||
|
(map (fn [{:keys [tool description]}]
|
||||||
|
(str tool ": " description)))
|
||||||
|
(clojure.string/join "\n"))
|
||||||
|
;; Save permission request as a system message
|
||||||
|
perm-msg (db/save-message store
|
||||||
|
{:session-id session-id
|
||||||
|
:role :system
|
||||||
|
:content description
|
||||||
|
:metadata {:type "permission-request"
|
||||||
|
:denials (:denials perm-req)
|
||||||
|
:tools (:tools perm-req)}})
|
||||||
|
msg-id (:id perm-msg)]
|
||||||
|
;; Store pending permission with message ID for later update
|
||||||
|
(set-pending-permission store session-id
|
||||||
|
(assoc perm-req :message-id msg-id))
|
||||||
(callback {:event :permission-request
|
(callback {:event :permission-request
|
||||||
:permission-request perm-req}))))))
|
:permission-request perm-req
|
||||||
|
:message-id msg-id
|
||||||
|
:message perm-msg})))))))
|
||||||
|
;; Update session with last known working directory
|
||||||
|
(when @last-working-dir
|
||||||
|
(log/info "Updating session working directory to:" @last-working-dir)
|
||||||
|
(db/update-session store session-id {:working-dir @last-working-dir}))
|
||||||
;; Update session status when stream ends
|
;; Update session status when stream ends
|
||||||
;; If there's a pending permission, set status to awaiting-permission
|
;; If there's a pending permission, set status to awaiting-permission
|
||||||
(let [new-status (if (get-pending-permission session-id)
|
(let [new-status (if (get-pending-permission store session-id)
|
||||||
:awaiting-permission
|
:awaiting-permission
|
||||||
:idle)]
|
:idle)]
|
||||||
(db/update-session store session-id {:status new-status}))
|
(db/update-session store session-id {:status new-status}))
|
||||||
@@ -163,18 +263,27 @@
|
|||||||
response-type: :accept, :deny, or :steer
|
response-type: :accept, :deny, or :steer
|
||||||
message: optional message for :deny or :steer responses"
|
message: optional message for :deny or :steer responses"
|
||||||
[store session-id response-type message]
|
[store session-id response-type message]
|
||||||
|
(log/debug "User action: permission-response" {:session-id session-id :response-type response-type :message message})
|
||||||
(let [session (db/get-session store session-id)
|
(let [session (db/get-session store session-id)
|
||||||
_ (when-not session
|
_ (when-not session
|
||||||
(throw (ex-info "Session not found" {:session-id session-id})))
|
(throw (ex-info "Session not found" {:session-id session-id})))
|
||||||
pending (get-pending-permission session-id)
|
pending (get-pending-permission store session-id)
|
||||||
_ (when-not pending
|
_ (when-not pending
|
||||||
(throw (ex-info "No pending permission request" {:session-id session-id})))
|
(throw (ex-info "No pending permission request" {:session-id session-id})))
|
||||||
adapter (get-adapter (:provider session))
|
adapter (get-adapter (:provider session))
|
||||||
;; Build spawn options based on response type
|
;; Use spawn-dir for spawning, fall back to working-dir for existing sessions
|
||||||
opts (cond-> {:working-dir (:working-dir session)}
|
spawn-dir (or (:spawn-dir session) (:working-dir session))
|
||||||
;; For :accept, grant the requested tools
|
;; Auto-accept tools from session setting (always included if enabled)
|
||||||
(= response-type :accept)
|
auto-accept-tools (when (:auto-accept-edits session)
|
||||||
(assoc :allowed-tools (:tools pending)))
|
["Write" "Edit"])
|
||||||
|
;; Tools granted from accepting the permission request
|
||||||
|
granted-tools (when (= response-type :accept) (:tools pending))
|
||||||
|
;; Combine both sets of allowed tools
|
||||||
|
all-allowed-tools (seq (distinct (concat auto-accept-tools granted-tools)))
|
||||||
|
;; Build spawn options
|
||||||
|
opts (cond-> {:working-dir spawn-dir}
|
||||||
|
all-allowed-tools
|
||||||
|
(assoc :allowed-tools (vec all-allowed-tools)))
|
||||||
;; Determine the message to send
|
;; Determine the message to send
|
||||||
send-msg (case response-type
|
send-msg (case response-type
|
||||||
:accept "continue"
|
:accept "continue"
|
||||||
@@ -184,11 +293,18 @@
|
|||||||
handle (adapter/spawn-session adapter
|
handle (adapter/spawn-session adapter
|
||||||
(:external-id session)
|
(:external-id session)
|
||||||
opts)]
|
opts)]
|
||||||
|
;; Update the permission message with the resolution status
|
||||||
|
(when-let [msg-id (:message-id pending)]
|
||||||
|
(let [current-msg (db/get-message store msg-id)
|
||||||
|
current-metadata (or (:metadata current-msg) {})
|
||||||
|
status (name response-type)]
|
||||||
|
(db/update-message store msg-id
|
||||||
|
{:metadata (assoc current-metadata :status status)})))
|
||||||
;; Clear pending permission
|
;; Clear pending permission
|
||||||
(clear-pending-permission session-id)
|
(clear-pending-permission store session-id)
|
||||||
;; Store new process handle
|
;; Store new process handle
|
||||||
(.put active-processes session-id handle)
|
(.put active-processes session-id handle)
|
||||||
(db/update-session store session-id {:status :running})
|
(db/update-session store session-id {:status :processing})
|
||||||
;; Send the response message
|
;; Send the response message
|
||||||
(adapter/send-message adapter handle send-msg)
|
(adapter/send-message adapter handle send-msg)
|
||||||
handle))
|
handle))
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello from OpenCode test
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello from OpenCode test
|
||||||
Reference in New Issue
Block a user