add git diffs and permission support
This commit is contained in:
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,63 +149,222 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as message (message.id)}
|
||||
<div class="rounded-lg border p-3 {roleStyles[message.role]}">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wide {message.role === 'user'
|
||||
? 'text-spice-400'
|
||||
: 'text-zinc-400'}"
|
||||
{@const isCollapsed = collapsedMessages.has(message.id)}
|
||||
{@const isCollapsible = shouldCollapse(message.content)}
|
||||
{@const permStatus = getPermissionStatus(message)}
|
||||
{#if isPermissionAccepted(message)}
|
||||
<!-- Permission accepted message (legacy format) -->
|
||||
<div class="rounded-lg border p-3 bg-green-500/20 border-green-500/30">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="h-4 w-4 text-green-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<div class="text-sm text-green-200 font-mono space-y-0.5">
|
||||
{#each message.content.split('\n') as line}
|
||||
<div>{line}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if isPermissionRequest(message)}
|
||||
<!-- Permission request message with file diffs -->
|
||||
{@const denials = getPermissionDenials(message)}
|
||||
{@const statusColor = permStatus === 'accept' ? 'green' : permStatus === 'deny' ? 'red' : permStatus === 'steer' ? 'amber' : 'amber'}
|
||||
{@const bgColor = permStatus === 'accept' ? 'bg-green-500/10 border-green-500/30' : permStatus === 'deny' ? 'bg-red-500/10 border-red-500/30' : permStatus === 'steer' ? 'bg-amber-500/10 border-amber-500/30' : 'bg-amber-500/10 border-amber-500/30'}
|
||||
<div class="rounded-lg border p-3 {bgColor}">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Status icon -->
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
{#if permStatus === 'accept'}
|
||||
<svg class="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else if permStatus === 'deny'}
|
||||
<svg class="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{:else if permStatus === 'steer'}
|
||||
<svg class="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Pending (shouldn't happen for historical, but fallback) -->
|
||||
<svg class="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium {permStatus === 'accept' ? 'text-green-200' : permStatus === 'deny' ? 'text-red-200' : 'text-amber-200'}">
|
||||
{#if permStatus === 'accept'}
|
||||
Permission granted
|
||||
{:else if permStatus === 'deny'}
|
||||
Permission denied
|
||||
{:else if permStatus === 'steer'}
|
||||
Redirected
|
||||
{:else}
|
||||
Permission requested
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<ul class="mt-2 space-y-2">
|
||||
{#each denials as denial, index}
|
||||
<li class="text-sm">
|
||||
{#if isFileOperation(denial.tool)}
|
||||
<!-- Expandable file operation -->
|
||||
<button
|
||||
class="w-full text-left flex items-center gap-2 text-zinc-300 font-mono hover:text-white transition-colors"
|
||||
on:click={() => toggleHistoryDenial(message.id, index)}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-zinc-500 transition-transform {expandedHistoryDenials.has(`${message.id}-${index}`) ? 'rotate-90' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="{permStatus === 'accept' ? 'text-green-400' : permStatus === 'deny' ? 'text-red-400' : 'text-amber-400'}">{denial.tool}:</span>
|
||||
<span class="truncate">{denial.description}</span>
|
||||
</button>
|
||||
|
||||
{#if expandedHistoryDenials.has(`${message.id}-${index}`)}
|
||||
<div class="mt-2">
|
||||
<FileDiff
|
||||
tool={denial.tool}
|
||||
input={denial.input}
|
||||
filePath={getFilePath(denial.tool, denial.input)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Non-file operation (Bash, etc) -->
|
||||
<div class="flex items-center gap-2 text-zinc-300 font-mono">
|
||||
<span class="{permStatus === 'accept' ? 'text-green-400' : permStatus === 'deny' ? 'text-red-400' : 'text-amber-400'}">{denial.tool}:</span>
|
||||
<span class="truncate">{denial.description}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded-lg border p-3 {roleStyles[message.role]} {isCollapsible ? 'cursor-pointer' : ''} relative"
|
||||
on:click={() => isCollapsible && toggleCollapse(message.id)}
|
||||
on:keydown={(e) => e.key === 'Enter' && isCollapsible && toggleCollapse(message.id)}
|
||||
role={isCollapsible ? 'button' : undefined}
|
||||
tabindex={isCollapsible ? 0 : undefined}
|
||||
>
|
||||
<div
|
||||
class="text-sm break-words font-mono leading-relaxed markdown-content {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
|
||||
>
|
||||
{roleLabels[message.role]}
|
||||
</span>
|
||||
{@html renderMarkdown(message.content)}
|
||||
</div>
|
||||
{#if isCollapsible}
|
||||
<span
|
||||
class="absolute right-3 top-3 text-zinc-500 transition-transform {isCollapsed ? '' : 'rotate-90'}"
|
||||
>
|
||||
›
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if isThinking && !streamingContent}
|
||||
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||
Assistant
|
||||
</span>
|
||||
<span class="flex gap-1">
|
||||
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0.1s"
|
||||
></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0.2s"
|
||||
></span>
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0.1s"
|
||||
></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0.2s"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if streamingContent}
|
||||
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||
Assistant
|
||||
</span>
|
||||
<span class="flex gap-1">
|
||||
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0.1s"
|
||||
></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0.2s"
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
|
||||
{streamingContent}<span class="animate-pulse">|</span>
|
||||
<div class="text-sm break-words font-mono leading-relaxed markdown-content">
|
||||
{@html renderMarkdown(streamingContent)}<span class="animate-pulse">|</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.markdown-content :global(p) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.markdown-content :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown-content :global(h1),
|
||||
.markdown-content :global(h2),
|
||||
.markdown-content :global(h3),
|
||||
.markdown-content :global(h4) {
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.markdown-content :global(h1) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.markdown-content :global(h2) {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.markdown-content :global(h3) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.markdown-content :global(code) {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.markdown-content :global(pre) {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.markdown-content :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.markdown-content :global(ul),
|
||||
.markdown-content :global(ol) {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.markdown-content :global(li) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.markdown-content :global(a) {
|
||||
color: #f97316;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.markdown-content :global(a:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.markdown-content :global(blockquote) {
|
||||
border-left: 3px solid #525252;
|
||||
padding-left: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.markdown-content :global(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-content :global(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { PermissionRequest } from '$lib/api';
|
||||
import type { PermissionRequest, WriteToolInput, EditToolInput } from '$lib/api';
|
||||
import FileDiff from './FileDiff.svelte';
|
||||
|
||||
export let permission: PermissionRequest;
|
||||
export let assistantName: string = 'Assistant';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
accept: void;
|
||||
@@ -10,6 +12,29 @@
|
||||
steer: void;
|
||||
}>();
|
||||
|
||||
// Track which denials are expanded
|
||||
let expandedDenials: Set<number> = new Set();
|
||||
|
||||
function toggleDenial(index: number) {
|
||||
if (expandedDenials.has(index)) {
|
||||
expandedDenials.delete(index);
|
||||
} else {
|
||||
expandedDenials.add(index);
|
||||
}
|
||||
expandedDenials = expandedDenials; // trigger reactivity
|
||||
}
|
||||
|
||||
function isFileOperation(tool: string): boolean {
|
||||
return tool === 'Write' || tool === 'Edit';
|
||||
}
|
||||
|
||||
function getFilePath(tool: string, input: unknown): string {
|
||||
if ((tool === 'Write' || tool === 'Edit') && input && typeof input === 'object') {
|
||||
return (input as { file_path?: string }).file_path || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function handleAccept() {
|
||||
dispatch('accept');
|
||||
}
|
||||
@@ -42,13 +67,45 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-amber-200">Claude needs permission:</p>
|
||||
<p class="text-sm font-medium text-amber-200">{assistantName} needs permission:</p>
|
||||
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each permission.denials as denial}
|
||||
<li class="text-sm text-zinc-300 font-mono truncate">
|
||||
<span class="text-amber-400">{denial.tool}:</span>
|
||||
{denial.description}
|
||||
<ul class="mt-2 space-y-2">
|
||||
{#each permission.denials as denial, index}
|
||||
<li class="text-sm">
|
||||
{#if isFileOperation(denial.tool)}
|
||||
<!-- Expandable file operation -->
|
||||
<button
|
||||
class="w-full text-left flex items-center gap-2 text-zinc-300 font-mono hover:text-white transition-colors"
|
||||
on:click={() => toggleDenial(index)}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-zinc-500 transition-transform {expandedDenials.has(index) ? 'rotate-90' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="text-amber-400">{denial.tool}:</span>
|
||||
<span class="truncate">{denial.description}</span>
|
||||
</button>
|
||||
|
||||
{#if expandedDenials.has(index)}
|
||||
<div class="mt-2">
|
||||
<FileDiff
|
||||
tool={denial.tool}
|
||||
input={denial.input}
|
||||
filePath={getFilePath(denial.tool, denial.input)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Non-file operation (Bash, etc) -->
|
||||
<div class="flex items-center gap-2 text-zinc-300 font-mono">
|
||||
<span class="text-amber-400">{denial.tool}:</span>
|
||||
<span class="truncate">{denial.description}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -56,19 +113,19 @@
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
on:click={handleAccept}
|
||||
class="btn bg-green-600 hover:bg-green-500 text-white text-sm px-3 py-1.5"
|
||||
class="btn bg-green-600 hover:bg-green-500 text-white text-sm px-3 py-1.5 rounded"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
on:click={handleDeny}
|
||||
class="btn bg-red-600 hover:bg-red-500 text-white text-sm px-3 py-1.5"
|
||||
class="btn bg-red-600 hover:bg-red-500 text-white text-sm px-3 py-1.5 rounded"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
on:click={handleSteer}
|
||||
class="btn bg-zinc-600 hover:bg-zinc-500 text-white text-sm px-3 py-1.5"
|
||||
class="btn bg-zinc-600 hover:bg-zinc-500 text-white text-sm px-3 py-1.5 rounded"
|
||||
>
|
||||
No, and...
|
||||
</button>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user