managed sessions only. allow for rename/delete
This commit is contained in:
Generated
+36
-9
@@ -12,6 +12,7 @@
|
||||
"@sveltejs/kit": "^2.5.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||
"@types/node": "^20.11.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"svelte": "^4.2.9",
|
||||
@@ -2755,6 +2756,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-basic-ssl": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz",
|
||||
"integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -5453,9 +5467,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-load-config": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
|
||||
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-5.1.0.tgz",
|
||||
"integrity": "sha512-G5AJ+IX0aD0dygOE0yFZQ/huFFMSNneyfp0e3/bT05a8OfPC5FUoZRPfGijUdGOJNMewJiwzcHJXFafFzeKFVA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5470,7 +5484,8 @@
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lilconfig": "^3.1.1"
|
||||
"lilconfig": "^3.1.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -5478,8 +5493,7 @@
|
||||
"peerDependencies": {
|
||||
"jiti": ">=1.21.0",
|
||||
"postcss": ">=8.0.9",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
"tsx": "^4.8.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jiti": {
|
||||
@@ -5490,9 +5504,6 @@
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7804,6 +7815,22 @@
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -15,6 +15,7 @@
|
||||
"@sveltejs/kit": "^2.5.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||
"@types/node": "^20.11.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"svelte": "^4.2.9",
|
||||
@@ -25,6 +26,5 @@
|
||||
"vite": "^5.0.12",
|
||||
"vite-plugin-pwa": "^0.19.2",
|
||||
"workbox-window": "^7.0.0"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-19
@@ -27,12 +27,15 @@ export interface Message {
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface DiscoveredSession {
|
||||
'external-id': string;
|
||||
provider: 'claude' | 'opencode';
|
||||
title?: string;
|
||||
'working-dir'?: string;
|
||||
'file-path'?: string;
|
||||
export interface PermissionDenial {
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequest {
|
||||
tools: string[];
|
||||
denials: PermissionDenial[];
|
||||
}
|
||||
|
||||
export interface StreamEvent {
|
||||
@@ -43,6 +46,9 @@ export interface StreamEvent {
|
||||
content?: string;
|
||||
type?: string;
|
||||
message?: string;
|
||||
cwd?: string;
|
||||
'permission-request'?: PermissionRequest;
|
||||
permissionRequest?: PermissionRequest;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
@@ -93,6 +99,13 @@ class ApiClient {
|
||||
await this.request<void>(`/sessions/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async updateSession(id: string, data: Partial<Session>): Promise<Session> {
|
||||
return this.request<Session>(`/sessions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(sessionId: string, message: string): Promise<{ status: string }> {
|
||||
return this.request<{ status: string }>(`/sessions/${sessionId}/send`, {
|
||||
method: 'POST',
|
||||
@@ -100,19 +113,14 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Discovery
|
||||
async discoverClaude(): Promise<DiscoveredSession[]> {
|
||||
return this.request<DiscoveredSession[]>('/discover/claude');
|
||||
}
|
||||
|
||||
async discoverOpenCode(): Promise<DiscoveredSession[]> {
|
||||
return this.request<DiscoveredSession[]>('/discover/opencode');
|
||||
}
|
||||
|
||||
async importSession(session: DiscoveredSession): Promise<Session> {
|
||||
return this.request<Session>('/import', {
|
||||
async respondToPermission(
|
||||
sessionId: string,
|
||||
response: 'accept' | 'deny' | 'steer',
|
||||
message?: string
|
||||
): Promise<{ status: string }> {
|
||||
return this.request<{ status: string }>(`/sessions/${sessionId}/permission`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(session)
|
||||
body: JSON.stringify({ response, message })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,7 +142,7 @@ export class WebSocketClient {
|
||||
private listeners: Map<string, Set<(event: StreamEvent) => void>> = new Map();
|
||||
private globalListeners: Set<(event: StreamEvent) => void> = new Set();
|
||||
|
||||
constructor(url: string = `ws://${window.location.host}/api/ws`) {
|
||||
constructor(url: string = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/ws`) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
textarea?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="border-t border-zinc-700 bg-zinc-900 safe-bottom">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export let messages: Message[] = [];
|
||||
export let streamingContent: string = '';
|
||||
export let isThinking: boolean = false;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let autoScroll = true;
|
||||
@@ -48,7 +49,7 @@
|
||||
on:scroll={handleScroll}
|
||||
class="flex-1 overflow-y-auto px-4 py-4 space-y-4"
|
||||
>
|
||||
{#if messages.length === 0 && !streamingContent}
|
||||
{#if messages.length === 0 && !streamingContent && !isThinking}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center text-zinc-500">
|
||||
<svg
|
||||
@@ -87,7 +88,26 @@
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if streamingContent}
|
||||
{#if isThinking && !streamingContent}
|
||||
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||
Assistant
|
||||
</span>
|
||||
<span class="flex gap-1">
|
||||
<span class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0.1s"
|
||||
></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 bg-spice-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0.2s"
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if streamingContent}
|
||||
<div class="rounded-lg border p-3 {roleStyles.assistant}">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-zinc-400">
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { PermissionRequest } from '$lib/api';
|
||||
|
||||
export let permission: PermissionRequest;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
accept: void;
|
||||
deny: void;
|
||||
steer: void;
|
||||
}>();
|
||||
|
||||
function handleAccept() {
|
||||
dispatch('accept');
|
||||
}
|
||||
|
||||
function handleDeny() {
|
||||
dispatch('deny');
|
||||
}
|
||||
|
||||
function handleSteer() {
|
||||
dispatch('steer');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-t border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<svg
|
||||
class="h-5 w-5 text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-amber-200">Claude needs permission:</p>
|
||||
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each permission.denials as denial}
|
||||
<li class="text-sm text-zinc-300 font-mono truncate">
|
||||
<span class="text-amber-400">{denial.tool}:</span>
|
||||
{denial.description}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
on:click={handleAccept}
|
||||
class="btn bg-green-600 hover:bg-green-500 text-white text-sm px-3 py-1.5"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
on:click={handleDeny}
|
||||
class="btn bg-red-600 hover:bg-red-500 text-white text-sm px-3 py-1.5"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
on:click={handleSteer}
|
||||
class="btn bg-zinc-600 hover:bg-zinc-500 text-white text-sm px-3 py-1.5"
|
||||
>
|
||||
No, and...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { Session } from '$lib/api';
|
||||
|
||||
export let session: Session;
|
||||
|
||||
const dispatch = createEventDispatcher<{ delete: void }>();
|
||||
|
||||
function handleDelete(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dispatch('delete');
|
||||
}
|
||||
|
||||
$: externalId = session['external-id'] || session.externalId || '';
|
||||
$: workingDir = session['working-dir'] || session.workingDir || '';
|
||||
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
|
||||
@@ -60,8 +69,17 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-right flex-shrink-0 flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-500">{formatTime(updatedAt)}</span>
|
||||
<button
|
||||
on:click={handleDelete}
|
||||
class="p-1 text-zinc-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||
title="Delete session"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable, derived, type Readable } from 'svelte/store';
|
||||
import { api, wsClient, type Session, type Message, type StreamEvent } from '$lib/api';
|
||||
import { api, wsClient, type Session, type Message, type StreamEvent, type PermissionRequest } from '$lib/api';
|
||||
|
||||
interface SessionsState {
|
||||
sessions: Session[];
|
||||
@@ -11,8 +11,10 @@ interface ActiveSessionState {
|
||||
session: Session | null;
|
||||
messages: Message[];
|
||||
streamingContent: string;
|
||||
isThinking: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
pendingPermission: PermissionRequest | null;
|
||||
}
|
||||
|
||||
function createSessionsStore() {
|
||||
@@ -59,6 +61,21 @@ function createSessionsStore() {
|
||||
session.id === id ? { ...session, ...data } : session
|
||||
)
|
||||
}));
|
||||
},
|
||||
async rename(id: string, title: string) {
|
||||
try {
|
||||
const updated = await api.updateSession(id, { title });
|
||||
update((s) => ({
|
||||
...s,
|
||||
sessions: s.sessions.map((session) =>
|
||||
session.id === id ? { ...session, ...updated } : session
|
||||
)
|
||||
}));
|
||||
return updated;
|
||||
} catch (e) {
|
||||
update((s) => ({ ...s, error: (e as Error).message }));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -68,8 +85,10 @@ function createActiveSessionStore() {
|
||||
session: null,
|
||||
messages: [],
|
||||
streamingContent: '',
|
||||
isThinking: false,
|
||||
loading: false,
|
||||
error: null
|
||||
error: null,
|
||||
pendingPermission: null
|
||||
});
|
||||
|
||||
let unsubscribeWs: (() => void) | null = null;
|
||||
@@ -119,7 +138,8 @@ function createActiveSessionStore() {
|
||||
update((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, userMessage],
|
||||
streamingContent: ''
|
||||
streamingContent: '',
|
||||
isThinking: true
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -150,6 +170,37 @@ function createActiveSessionStore() {
|
||||
};
|
||||
});
|
||||
},
|
||||
async respondToPermission(response: 'accept' | 'deny' | 'steer', message?: string) {
|
||||
const state = get();
|
||||
if (!state.session || !state.pendingPermission) return;
|
||||
|
||||
// Clear pending permission immediately
|
||||
update((s) => ({ ...s, pendingPermission: null }));
|
||||
|
||||
try {
|
||||
await api.respondToPermission(state.session.id, response, message);
|
||||
} catch (e) {
|
||||
update((s) => ({ ...s, error: (e as Error).message }));
|
||||
}
|
||||
},
|
||||
async rename(title: string) {
|
||||
const state = get();
|
||||
if (!state.session) return;
|
||||
|
||||
try {
|
||||
const updated = await api.updateSession(state.session.id, { title });
|
||||
update((s) => ({
|
||||
...s,
|
||||
session: s.session ? { ...s.session, ...updated } : null
|
||||
}));
|
||||
// Also update in the sessions list
|
||||
sessions.updateSession(state.session.id, { title });
|
||||
return updated;
|
||||
} catch (e) {
|
||||
update((s) => ({ ...s, error: (e as Error).message }));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
if (unsubscribeWs) {
|
||||
unsubscribeWs();
|
||||
@@ -159,8 +210,10 @@ function createActiveSessionStore() {
|
||||
session: null,
|
||||
messages: [],
|
||||
streamingContent: '',
|
||||
isThinking: false,
|
||||
loading: false,
|
||||
error: null
|
||||
error: null,
|
||||
pendingPermission: null
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -172,11 +225,20 @@ function createActiveSessionStore() {
|
||||
}
|
||||
|
||||
function handleStreamEvent(event: StreamEvent) {
|
||||
if (event.event === 'content-delta' && event.text) {
|
||||
update((s) => ({ ...s, streamingContent: s.streamingContent + event.text }));
|
||||
if (event.event === 'init' && event.cwd) {
|
||||
// Update session's working directory from init event
|
||||
update((s) => {
|
||||
if (!s.session) return s;
|
||||
return {
|
||||
...s,
|
||||
session: { ...s.session, 'working-dir': event.cwd, workingDir: event.cwd }
|
||||
};
|
||||
});
|
||||
} else if (event.event === 'content-delta' && event.text) {
|
||||
update((s) => ({ ...s, streamingContent: s.streamingContent + event.text, isThinking: false }));
|
||||
} else if (event.event === 'message-stop') {
|
||||
update((s) => {
|
||||
if (!s.streamingContent || !s.session) return s;
|
||||
if (!s.streamingContent || !s.session) return { ...s, isThinking: false };
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `stream-${Date.now()}`,
|
||||
@@ -189,11 +251,17 @@ function createActiveSessionStore() {
|
||||
return {
|
||||
...s,
|
||||
messages: [...s.messages, assistantMessage],
|
||||
streamingContent: ''
|
||||
streamingContent: '',
|
||||
isThinking: false
|
||||
};
|
||||
});
|
||||
} else if (event.event === 'permission-request') {
|
||||
const permReq = event['permission-request'] || event.permissionRequest;
|
||||
if (permReq) {
|
||||
update((s) => ({ ...s, pendingPermission: permReq }));
|
||||
}
|
||||
} else if (event.event === 'error') {
|
||||
update((s) => ({ ...s, error: event.message || 'Stream error' }));
|
||||
update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-146
@@ -1,22 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { sessions, sortedSessions, runningSessions } from '$lib/stores/sessions';
|
||||
import { api, type DiscoveredSession } from '$lib/api';
|
||||
import type { Session } from '$lib/api';
|
||||
import SessionCard from '$lib/components/SessionCard.svelte';
|
||||
|
||||
let discovering = false;
|
||||
let discoveredSessions: DiscoveredSession[] = [];
|
||||
let showDiscovery = false;
|
||||
let showNewSessionMenu = false;
|
||||
let creating = false;
|
||||
|
||||
async function refresh() {
|
||||
await sessions.load();
|
||||
}
|
||||
|
||||
async function createNewSession() {
|
||||
async function createNewSession(provider: Session['provider']) {
|
||||
creating = true;
|
||||
showNewSessionMenu = false;
|
||||
try {
|
||||
const session = await sessions.create({ provider: 'claude' });
|
||||
const session = await sessions.create({ provider });
|
||||
await goto(`/session/${session.id}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to create session:', e);
|
||||
@@ -25,28 +24,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSessions() {
|
||||
discovering = true;
|
||||
try {
|
||||
const [claude, opencode] = await Promise.all([
|
||||
api.discoverClaude().catch(() => []),
|
||||
api.discoverOpenCode().catch(() => [])
|
||||
]);
|
||||
discoveredSessions = [...claude, ...opencode];
|
||||
showDiscovery = true;
|
||||
} finally {
|
||||
discovering = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importSession(session: DiscoveredSession) {
|
||||
await api.importSession(session);
|
||||
discoveredSessions = discoveredSessions.filter(
|
||||
(s) => s['external-id'] !== session['external-id']
|
||||
);
|
||||
await sessions.load();
|
||||
if (discoveredSessions.length === 0) {
|
||||
showDiscovery = false;
|
||||
async function deleteSession(id: string) {
|
||||
if (confirm('Delete this session?')) {
|
||||
await sessions.delete(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -55,6 +35,8 @@
|
||||
<title>Spiceflow</title>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window on:click={() => (showNewSessionMenu = false)} />
|
||||
|
||||
<!-- Header -->
|
||||
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -64,23 +46,46 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
on:click={createNewSession}
|
||||
disabled={creating}
|
||||
class="btn btn-primary p-2"
|
||||
title="New Session"
|
||||
>
|
||||
{#if creating}
|
||||
<svg class="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<div class="relative">
|
||||
<button
|
||||
on:click|stopPropagation={() => (showNewSessionMenu = !showNewSessionMenu)}
|
||||
disabled={creating}
|
||||
class="btn btn-primary p-2"
|
||||
title="New Session"
|
||||
>
|
||||
{#if creating}
|
||||
<svg class="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if showNewSessionMenu}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl z-50 min-w-[140px] overflow-hidden"
|
||||
>
|
||||
<button
|
||||
on:click={() => createNewSession('claude')}
|
||||
class="w-full px-4 py-2.5 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full bg-spice-500"></span>
|
||||
Claude Code
|
||||
</button>
|
||||
<button
|
||||
on:click={() => createNewSession('opencode')}
|
||||
class="w-full px-4 py-2.5 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
OpenCode
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={refresh}
|
||||
@@ -103,33 +108,6 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
on:click={discoverSessions}
|
||||
disabled={discovering}
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{#if discovering}
|
||||
<span class="animate-spin inline-block mr-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
Discover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,88 +168,15 @@
|
||||
</svg>
|
||||
<h2 class="text-lg font-medium text-zinc-300 mb-2">No sessions yet</h2>
|
||||
<p class="text-sm mb-4">
|
||||
Click the + button to start a new chat, or "Discover" to find existing Claude Code sessions.
|
||||
Click the + button to start a new session.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 space-y-3">
|
||||
{#each $sortedSessions as session (session.id)}
|
||||
<SessionCard {session} />
|
||||
<SessionCard {session} on:delete={() => deleteSession(session.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Discovery Modal -->
|
||||
{#if showDiscovery}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center"
|
||||
on:click={() => (showDiscovery = false)}
|
||||
on:keydown={(e) => e.key === 'Escape' && (showDiscovery = false)}
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 w-full sm:max-w-lg sm:rounded-xl rounded-t-xl border border-zinc-700 max-h-[80vh] flex flex-col"
|
||||
on:click|stopPropagation
|
||||
on:keydown|stopPropagation
|
||||
role="document"
|
||||
>
|
||||
<div class="p-4 border-b border-zinc-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Discovered Sessions</h2>
|
||||
<button
|
||||
on:click={() => (showDiscovery = false)}
|
||||
class="p-1 hover:bg-zinc-700 rounded"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if discoveredSessions.length === 0}
|
||||
<p class="text-zinc-500 text-center py-8">No new sessions found.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each discoveredSessions as session}
|
||||
<div class="card flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0 mr-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-medium uppercase {session.provider === 'claude'
|
||||
? 'text-spice-400'
|
||||
: 'text-emerald-400'}"
|
||||
>
|
||||
{session.provider}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-300 truncate">
|
||||
{session.title || session['external-id'].slice(0, 8)}
|
||||
</p>
|
||||
{#if session['working-dir']}
|
||||
<p class="text-xs text-zinc-500 truncate">
|
||||
{session['working-dir']}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
on:click={() => importSession(session)}
|
||||
class="btn btn-primary text-sm py-1.5"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { activeSession } from '$lib/stores/sessions';
|
||||
import MessageList from '$lib/components/MessageList.svelte';
|
||||
import InputBar from '$lib/components/InputBar.svelte';
|
||||
import PermissionRequest from '$lib/components/PermissionRequest.svelte';
|
||||
|
||||
$: sessionId = $page.params.id;
|
||||
|
||||
let inputBar: InputBar;
|
||||
let steerMode = false;
|
||||
let isEditingTitle = false;
|
||||
let editedTitle = '';
|
||||
let titleInput: HTMLInputElement;
|
||||
|
||||
onMount(() => {
|
||||
if (sessionId) {
|
||||
activeSession.load(sessionId);
|
||||
@@ -19,13 +26,64 @@
|
||||
});
|
||||
|
||||
function handleSend(event: CustomEvent<string>) {
|
||||
activeSession.sendMessage(event.detail);
|
||||
if (steerMode && $activeSession.pendingPermission) {
|
||||
// Send as steer response
|
||||
activeSession.respondToPermission('steer', event.detail);
|
||||
steerMode = false;
|
||||
} else {
|
||||
activeSession.sendMessage(event.detail);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePermissionAccept() {
|
||||
activeSession.respondToPermission('accept');
|
||||
}
|
||||
|
||||
function handlePermissionDeny() {
|
||||
activeSession.respondToPermission('deny');
|
||||
}
|
||||
|
||||
function handlePermissionSteer() {
|
||||
steerMode = true;
|
||||
// Focus the input bar
|
||||
inputBar?.focus();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
async function startEditingTitle() {
|
||||
if (!session) return;
|
||||
editedTitle = session.title || '';
|
||||
isEditingTitle = true;
|
||||
await tick();
|
||||
titleInput?.focus();
|
||||
titleInput?.select();
|
||||
}
|
||||
|
||||
async function saveTitle() {
|
||||
if (!session || !isEditingTitle) return;
|
||||
const newTitle = editedTitle.trim();
|
||||
isEditingTitle = false;
|
||||
if (newTitle !== (session.title || '')) {
|
||||
await activeSession.rename(newTitle);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditTitle() {
|
||||
isEditingTitle = false;
|
||||
editedTitle = '';
|
||||
}
|
||||
|
||||
function handleTitleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
saveTitle();
|
||||
} else if (event.key === 'Escape') {
|
||||
cancelEditTitle();
|
||||
}
|
||||
}
|
||||
|
||||
$: session = $activeSession.session;
|
||||
$: externalId = session?.['external-id'] || session?.externalId || '';
|
||||
$: workingDir = session?.['working-dir'] || session?.workingDir || '';
|
||||
@@ -66,9 +124,24 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full {statusColors[session.status] || statusColors.idle}"></span>
|
||||
<h1 class="font-semibold truncate">
|
||||
{session.title || `Session ${shortId}`}
|
||||
</h1>
|
||||
{#if isEditingTitle}
|
||||
<input
|
||||
bind:this={titleInput}
|
||||
bind:value={editedTitle}
|
||||
on:blur={saveTitle}
|
||||
on:keydown={handleTitleKeydown}
|
||||
class="font-semibold bg-zinc-800 border border-zinc-600 rounded px-2 py-0.5 text-zinc-100 focus:outline-none focus:border-spice-500 w-full max-w-[200px]"
|
||||
placeholder="Session name"
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
on:click={startEditingTitle}
|
||||
class="font-semibold truncate text-left hover:text-spice-400 transition-colors"
|
||||
title="Click to rename"
|
||||
>
|
||||
{session.title || `Session ${shortId}`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if projectName}
|
||||
<p class="text-xs text-zinc-500 truncate">{projectName}</p>
|
||||
@@ -107,11 +180,33 @@
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<MessageList messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} />
|
||||
{#if workingDir}
|
||||
<div class="flex-shrink-0 px-4 py-2 bg-zinc-900/50 border-b border-zinc-800 flex items-center gap-2 text-xs text-zinc-500">
|
||||
<svg class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span class="truncate font-mono">{workingDir}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<MessageList messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} />
|
||||
|
||||
{#if $activeSession.pendingPermission}
|
||||
<PermissionRequest
|
||||
permission={$activeSession.pendingPermission}
|
||||
on:accept={handlePermissionAccept}
|
||||
on:deny={handlePermissionDeny}
|
||||
on:steer={handlePermissionSteer}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<InputBar
|
||||
bind:this={inputBar}
|
||||
on:send={handleSend}
|
||||
disabled={session?.status === 'running' && $activeSession.streamingContent !== ''}
|
||||
placeholder={session?.status === 'running' ? 'Waiting for response...' : 'Type a message...'}
|
||||
placeholder={steerMode
|
||||
? 'Tell Claude what to do instead...'
|
||||
: session?.status === 'running'
|
||||
? 'Waiting for response...'
|
||||
: 'Type a message...'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import basicSsl from '@vitejs/plugin-basic-ssl';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
basicSsl(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
||||
@@ -59,6 +61,7 @@ export default defineConfig({
|
||||
})
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
|
||||
Reference in New Issue
Block a user