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