managed sessions only. allow for rename/delete

This commit is contained in:
2026-01-19 19:34:58 -05:00
parent e2048d8b69
commit 313ac44337
32 changed files with 1759 additions and 331 deletions
+36 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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">
+22 -2
View File
@@ -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>
+19 -1
View File
@@ -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>
+77 -9
View File
@@ -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
View File
@@ -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}
+102 -7
View File
@@ -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}
+3
View File
@@ -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',