init commit

This commit is contained in:
2026-01-18 22:07:48 -05:00
parent 9c019e3d41
commit 56dde9cf91
43 changed files with 2925 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
{
"name": "spiceflow-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@types/node": "^20.11.0",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"svelte": "^4.2.9",
"svelte-check": "^3.6.3",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vite-plugin-pwa": "^0.19.2",
"workbox-window": "^7.0.0"
},
"dependencies": {}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+64
View File
@@ -0,0 +1,64 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
-webkit-tap-highlight-color: transparent;
}
body {
@apply antialiased;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-zinc-800;
}
::-webkit-scrollbar-thumb {
@apply bg-zinc-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-500;
}
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors;
}
.btn-primary {
@apply bg-spice-500 hover:bg-spice-600 text-white;
}
.btn-secondary {
@apply bg-zinc-700 hover:bg-zinc-600 text-zinc-100;
}
.card {
@apply bg-zinc-800 rounded-xl p-4 border border-zinc-700;
}
.input {
@apply w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg
text-zinc-100 placeholder:text-zinc-500
focus:outline-none focus:ring-2 focus:ring-spice-500 focus:border-transparent;
}
}
@layer utilities {
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom, 1rem);
}
.safe-top {
padding-top: env(safe-area-inset-top, 0);
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="theme-color" content="#f97316" />
<meta name="description" content="Spiceflow - AI Session Orchestration" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-zinc-900 text-zinc-100">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+236
View File
@@ -0,0 +1,236 @@
const API_BASE = '/api';
export interface Session {
id: string;
provider: 'claude' | 'opencode';
'external-id'?: string;
externalId?: string;
title?: string;
'working-dir'?: string;
workingDir?: string;
status: 'idle' | 'running' | 'completed';
'created-at'?: string;
createdAt?: string;
'updated-at'?: string;
updatedAt?: string;
messages?: Message[];
}
export interface Message {
id: string;
'session-id': string;
sessionId?: string;
role: 'user' | 'assistant' | 'system';
content: string;
metadata?: Record<string, unknown>;
'created-at'?: string;
createdAt?: string;
}
export interface DiscoveredSession {
'external-id': string;
provider: 'claude' | 'opencode';
title?: string;
'working-dir'?: string;
'file-path'?: string;
}
export interface StreamEvent {
event?: string;
'session-id'?: string;
sessionId?: string;
text?: string;
content?: string;
type?: string;
message?: string;
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = API_BASE) {
this.baseUrl = baseUrl;
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || `HTTP ${response.status}`);
}
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
// Sessions
async getSessions(): Promise<Session[]> {
return this.request<Session[]>('/sessions');
}
async getSession(id: string): Promise<Session> {
return this.request<Session>(`/sessions/${id}`);
}
async createSession(session: Partial<Session>): Promise<Session> {
return this.request<Session>('/sessions', {
method: 'POST',
body: JSON.stringify(session)
});
}
async deleteSession(id: string): Promise<void> {
await this.request<void>(`/sessions/${id}`, { method: 'DELETE' });
}
async sendMessage(sessionId: string, message: string): Promise<{ status: string }> {
return this.request<{ status: string }>(`/sessions/${sessionId}/send`, {
method: 'POST',
body: JSON.stringify({ message })
});
}
// 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', {
method: 'POST',
body: JSON.stringify(session)
});
}
// Health
async health(): Promise<{ status: string; service: string }> {
return this.request<{ status: string; service: string }>('/health');
}
}
export const api = new ApiClient();
// WebSocket connection
export class WebSocketClient {
private ws: WebSocket | null = null;
private url: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
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`) {
this.url = url;
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.ws?.readyState === WebSocket.OPEN) {
resolve();
return;
}
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
resolve();
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.attemptReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as StreamEvent;
this.handleMessage(data);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
});
}
private handleMessage(event: StreamEvent) {
// Notify global listeners
this.globalListeners.forEach((listener) => listener(event));
// Notify session-specific listeners
const sessionId = event['session-id'] || event.sessionId;
if (sessionId) {
const sessionListeners = this.listeners.get(sessionId);
sessionListeners?.forEach((listener) => listener(event));
}
}
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
setTimeout(() => {
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
this.connect().catch(() => {});
}, delay);
}
subscribe(sessionId: string, callback: (event: StreamEvent) => void) {
if (!this.listeners.has(sessionId)) {
this.listeners.set(sessionId, new Set());
}
this.listeners.get(sessionId)!.add(callback);
// Send subscribe message
this.send({ type: 'subscribe', 'session-id': sessionId });
return () => {
this.listeners.get(sessionId)?.delete(callback);
this.send({ type: 'unsubscribe', 'session-id': sessionId });
};
}
onMessage(callback: (event: StreamEvent) => void) {
this.globalListeners.add(callback);
return () => this.globalListeners.delete(callback);
}
send(data: Record<string, unknown>) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
disconnect() {
this.ws?.close();
this.ws = null;
}
}
export const wsClient = new WebSocketClient();
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let disabled = false;
export let placeholder = 'Type a message...';
const dispatch = createEventDispatcher<{ send: string }>();
let message = '';
let textarea: HTMLTextAreaElement;
function handleSubmit() {
const trimmed = message.trim();
if (!trimmed || disabled) return;
dispatch('send', trimmed);
message = '';
resizeTextarea();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
function resizeTextarea() {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
}
</script>
<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">
<textarea
bind:this={textarea}
bind:value={message}
on:keydown={handleKeydown}
on:input={resizeTextarea}
{placeholder}
{disabled}
rows="1"
class="input resize-none min-h-[44px] max-h-[150px] py-3"
></textarea>
<button
type="submit"
disabled={disabled || !message.trim()}
class="btn btn-primary h-[44px] px-4 flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"
/>
</svg>
</button>
</div>
</form>
@@ -0,0 +1,114 @@
<script lang="ts">
import type { Message } from '$lib/api';
import { onMount, afterUpdate } from 'svelte';
export let messages: Message[] = [];
export let streamingContent: string = '';
let container: HTMLDivElement;
let autoScroll = true;
function scrollToBottom() {
if (autoScroll && container) {
container.scrollTop = container.scrollHeight;
}
}
function handleScroll() {
if (!container) return;
const threshold = 100;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
autoScroll = distanceFromBottom < threshold;
}
onMount(() => {
scrollToBottom();
});
afterUpdate(() => {
scrollToBottom();
});
const roleStyles = {
user: 'bg-spice-500/20 border-spice-500/30',
assistant: 'bg-zinc-800 border-zinc-700',
system: 'bg-blue-500/20 border-blue-500/30 text-blue-200'
};
const roleLabels = {
user: 'You',
assistant: 'Assistant',
system: 'System'
};
</script>
<div
bind:this={container}
on:scroll={handleScroll}
class="flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
{#if messages.length === 0 && !streamingContent}
<div class="h-full flex items-center justify-center">
<div class="text-center text-zinc-500">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 mx-auto mb-3 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p>No messages yet</p>
<p class="text-sm mt-1">Send a message to start the conversation</p>
</div>
</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'}"
>
{roleLabels[message.role]}
</span>
</div>
<div class="text-sm whitespace-pre-wrap break-words font-mono leading-relaxed">
{message.content}
</div>
</div>
{/each}
{#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>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { Session } from '$lib/api';
export let session: Session;
$: externalId = session['external-id'] || session.externalId || '';
$: 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 '';
const date = new Date(iso);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'Just now';
}
const statusColors = {
idle: 'bg-zinc-600',
running: 'bg-green-500 animate-pulse',
completed: 'bg-blue-500'
};
const providerColors = {
claude: 'text-spice-400',
opencode: 'text-emerald-400'
};
</script>
<a
href="/session/{session.id}"
class="card block hover:border-spice-500/50 transition-all active:scale-[0.98]"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="w-2 h-2 rounded-full {statusColors[session.status]}"></span>
<span class="text-xs font-medium uppercase tracking-wide {providerColors[session.provider]}">
{session.provider}
</span>
</div>
<h3 class="font-medium text-zinc-100 truncate">
{session.title || `Session ${shortId}`}
</h3>
{#if projectName}
<p class="text-sm text-zinc-400 truncate mt-1">
{projectName}
</p>
{/if}
</div>
<div class="text-right flex-shrink-0">
<span class="text-xs text-zinc-500">{formatTime(updatedAt)}</span>
</div>
</div>
</a>
+3
View File
@@ -0,0 +1,3 @@
// Spiceflow client library exports
export * from './api';
export * from './stores/sessions';
+207
View File
@@ -0,0 +1,207 @@
import { writable, derived, type Readable } from 'svelte/store';
import { api, wsClient, type Session, type Message, type StreamEvent } from '$lib/api';
interface SessionsState {
sessions: Session[];
loading: boolean;
error: string | null;
}
interface ActiveSessionState {
session: Session | null;
messages: Message[];
streamingContent: string;
loading: boolean;
error: string | null;
}
function createSessionsStore() {
const { subscribe, set, update } = writable<SessionsState>({
sessions: [],
loading: false,
error: null
});
return {
subscribe,
async load() {
update((s) => ({ ...s, loading: true, error: null }));
try {
const sessions = await api.getSessions();
update((s) => ({ ...s, sessions, loading: false }));
} catch (e) {
update((s) => ({ ...s, loading: false, error: (e as Error).message }));
}
},
async delete(id: string) {
try {
await api.deleteSession(id);
update((s) => ({
...s,
sessions: s.sessions.filter((session) => session.id !== id)
}));
} catch (e) {
update((s) => ({ ...s, error: (e as Error).message }));
}
},
updateSession(id: string, data: Partial<Session>) {
update((s) => ({
...s,
sessions: s.sessions.map((session) =>
session.id === id ? { ...session, ...data } : session
)
}));
}
};
}
function createActiveSessionStore() {
const { subscribe, set, update } = writable<ActiveSessionState>({
session: null,
messages: [],
streamingContent: '',
loading: false,
error: null
});
let unsubscribeWs: (() => void) | null = null;
return {
subscribe,
async load(id: string) {
update((s) => ({ ...s, loading: true, error: null, streamingContent: '' }));
// Unsubscribe from previous session
if (unsubscribeWs) {
unsubscribeWs();
unsubscribeWs = null;
}
try {
const session = await api.getSession(id);
update((s) => ({
...s,
session,
messages: session.messages || [],
loading: false
}));
// Subscribe to WebSocket updates
await wsClient.connect();
unsubscribeWs = wsClient.subscribe(id, (event) => {
handleStreamEvent(event);
});
} catch (e) {
update((s) => ({ ...s, loading: false, error: (e as Error).message }));
}
},
async sendMessage(message: string) {
const state = get();
if (!state.session) return;
// Add user message immediately
const userMessage: Message = {
id: `temp-${Date.now()}`,
'session-id': state.session.id,
role: 'user',
content: message,
'created-at': new Date().toISOString()
};
update((s) => ({
...s,
messages: [...s.messages, userMessage],
streamingContent: ''
}));
try {
await api.sendMessage(state.session.id, message);
} catch (e) {
update((s) => ({ ...s, error: (e as Error).message }));
}
},
appendStreamContent(text: string) {
update((s) => ({ ...s, streamingContent: s.streamingContent + text }));
},
finalizeStream() {
update((s) => {
if (!s.streamingContent || !s.session) return s;
const assistantMessage: Message = {
id: `stream-${Date.now()}`,
'session-id': s.session.id,
role: 'assistant',
content: s.streamingContent,
'created-at': new Date().toISOString()
};
return {
...s,
messages: [...s.messages, assistantMessage],
streamingContent: ''
};
});
},
clear() {
if (unsubscribeWs) {
unsubscribeWs();
unsubscribeWs = null;
}
set({
session: null,
messages: [],
streamingContent: '',
loading: false,
error: null
});
}
};
function get(): ActiveSessionState {
let state: ActiveSessionState;
subscribe((s) => (state = s))();
return state!;
}
function handleStreamEvent(event: StreamEvent) {
if (event.event === 'content-delta' && event.text) {
update((s) => ({ ...s, streamingContent: s.streamingContent + event.text }));
} else if (event.event === 'message-stop') {
update((s) => {
if (!s.streamingContent || !s.session) return s;
const assistantMessage: Message = {
id: `stream-${Date.now()}`,
'session-id': s.session.id,
role: 'assistant',
content: s.streamingContent,
'created-at': new Date().toISOString()
};
return {
...s,
messages: [...s.messages, assistantMessage],
streamingContent: ''
};
});
} else if (event.event === 'error') {
update((s) => ({ ...s, error: event.message || 'Stream error' }));
}
}
}
export const sessions = createSessionsStore();
export const activeSession = createActiveSessionStore();
// Derived stores
export const sortedSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
[...$sessions.sessions].sort((a, b) => {
const aDate = a['updated-at'] || a.updatedAt || a['created-at'] || a.createdAt || '';
const bDate = b['updated-at'] || b.updatedAt || b['created-at'] || b.createdAt || '';
return bDate.localeCompare(aDate);
})
);
export const runningSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
$sessions.sessions.filter((s) => s.status === 'running')
);
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { sessions } from '$lib/stores/sessions';
onMount(() => {
sessions.load();
});
</script>
<div class="h-screen flex flex-col bg-zinc-900 text-zinc-100 safe-top">
<slot />
</div>
+2
View File
@@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;
+245
View File
@@ -0,0 +1,245 @@
<script lang="ts">
import { sessions, sortedSessions, runningSessions } from '$lib/stores/sessions';
import { api, type DiscoveredSession } from '$lib/api';
import SessionCard from '$lib/components/SessionCard.svelte';
let discovering = false;
let discoveredSessions: DiscoveredSession[] = [];
let showDiscovery = false;
async function refresh() {
await sessions.load();
}
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;
}
}
</script>
<svelte:head>
<title>Spiceflow</title>
</svelte:head>
<!-- Header -->
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-bold text-spice-500">Spiceflow</h1>
<p class="text-xs text-zinc-500">The spice must flow</p>
</div>
<div class="flex items-center gap-2">
<button
on:click={refresh}
disabled={$sessions.loading}
class="btn btn-secondary p-2"
title="Refresh"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 {$sessions.loading ? 'animate-spin' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<button
on:click={discoverSessions}
disabled={discovering}
class="btn btn-primary"
>
{#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>
{#if $runningSessions.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
</span>
</div>
{/if}
</header>
<!-- Content -->
<main class="flex-1 overflow-y-auto">
{#if $sessions.error}
<div class="m-4 p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
{$sessions.error}
</div>
{/if}
{#if $sessions.loading && $sortedSessions.length === 0}
<div class="flex items-center justify-center h-full">
<div class="text-center text-zinc-500">
<svg class="animate-spin h-8 w-8 mx-auto mb-3" 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>
<p>Loading sessions...</p>
</div>
</div>
{:else if $sortedSessions.length === 0}
<div class="flex items-center justify-center h-full p-4">
<div class="text-center text-zinc-500 max-w-xs">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<h2 class="text-lg font-medium text-zinc-300 mb-2">No sessions yet</h2>
<p class="text-sm mb-4">
Click "Discover" to find existing Claude Code or OpenCode sessions on your machine.
</p>
</div>
</div>
{:else}
<div class="p-4 space-y-3">
{#each $sortedSessions as session (session.id)}
<SessionCard {session} />
{/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}
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import { activeSession } from '$lib/stores/sessions';
import MessageList from '$lib/components/MessageList.svelte';
import InputBar from '$lib/components/InputBar.svelte';
$: sessionId = $page.params.id;
onMount(() => {
if (sessionId) {
activeSession.load(sessionId);
}
});
onDestroy(() => {
activeSession.clear();
});
function handleSend(event: CustomEvent<string>) {
activeSession.sendMessage(event.detail);
}
function goBack() {
goto('/');
}
$: session = $activeSession.session;
$: externalId = session?.['external-id'] || session?.externalId || '';
$: workingDir = session?.['working-dir'] || session?.workingDir || '';
$: shortId = externalId.slice(0, 8);
$: projectName = workingDir.split('/').pop() || '';
const statusColors: Record<string, string> = {
idle: 'bg-zinc-600',
running: 'bg-green-500 animate-pulse',
completed: 'bg-blue-500'
};
</script>
<svelte:head>
<title>{session?.title || `Session ${shortId}`} - Spiceflow</title>
</svelte:head>
<!-- Header -->
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
<div class="flex items-center gap-3">
<button
on:click={goBack}
class="p-1 -ml-1 hover:bg-zinc-700 rounded transition-colors"
aria-label="Go back"
>
<svg class="h-6 w-6" 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>
</button>
{#if $activeSession.loading}
<div class="flex-1">
<div class="h-5 w-32 bg-zinc-700 rounded animate-pulse"></div>
<div class="h-4 w-24 bg-zinc-800 rounded animate-pulse mt-1"></div>
</div>
{:else if session}
<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>
</div>
{#if projectName}
<p class="text-xs text-zinc-500 truncate">{projectName}</p>
{/if}
</div>
<span
class="text-xs font-medium uppercase {session.provider === 'claude'
? 'text-spice-400'
: 'text-emerald-400'}"
>
{session.provider}
</span>
{/if}
</div>
</header>
<!-- Content -->
{#if $activeSession.error}
<div class="flex-1 flex items-center justify-center p-4">
<div class="text-center">
<div class="text-red-400 mb-4">{$activeSession.error}</div>
<button on:click={goBack} class="btn btn-secondary">Go Back</button>
</div>
</div>
{:else if $activeSession.loading}
<div class="flex-1 flex items-center justify-center">
<svg class="animate-spin h-8 w-8 text-spice-500" 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>
</div>
{:else}
<MessageList messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} />
<InputBar
on:send={handleSend}
disabled={session?.status === 'running' && $activeSession.streamingContent !== ''}
placeholder={session?.status === 'running' ? 'Waiting for response...' : 'Type a message...'}
/>
{/if}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<defs>
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316"/>
<stop offset="100%" style="stop-color:#ea580c"/>
</linearGradient>
</defs>
<rect width="192" height="192" rx="38" fill="#18181b"/>
<path d="M96 38 L144 67 L144 125 L96 154 L48 125 L48 67 Z" fill="url(#spice)"/>
<circle cx="96" cy="96" r="23" fill="#18181b"/>
<circle cx="96" cy="96" r="12" fill="url(#spice)"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

+1
View File
@@ -0,0 +1 @@
favicon.svg
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316"/>
<stop offset="100%" style="stop-color:#ea580c"/>
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="#18181b"/>
<path d="M50 20 L75 35 L75 65 L50 80 L25 65 L25 35 Z" fill="url(#spice)"/>
<circle cx="50" cy="50" r="12" fill="#18181b"/>
<circle cx="50" cy="50" r="6" fill="url(#spice)"/>
</svg>

After

Width:  |  Height:  |  Size: 525 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<defs>
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316"/>
<stop offset="100%" style="stop-color:#ea580c"/>
</linearGradient>
</defs>
<rect width="192" height="192" rx="38" fill="#18181b"/>
<path d="M96 38 L144 67 L144 125 L96 154 L48 125 L48 67 Z" fill="url(#spice)"/>
<circle cx="96" cy="96" r="23" fill="#18181b"/>
<circle cx="96" cy="96" r="12" fill="url(#spice)"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<defs>
<linearGradient id="spice" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316"/>
<stop offset="100%" style="stop-color:#ea580c"/>
</linearGradient>
</defs>
<rect width="192" height="192" rx="38" fill="#18181b"/>
<path d="M96 38 L144 67 L144 125 L96 154 L48 125 L48 67 Z" fill="url(#spice)"/>
<circle cx="96" cy="96" r="23" fill="#18181b"/>
<circle cx="96" cy="96" r="12" fill="url(#spice)"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

+21
View File
@@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
}),
paths: {
base: ''
}
}
};
export default config;
+27
View File
@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
spice: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
950: '#431407'
}
},
fontFamily: {
mono: ['JetBrains Mono', 'Fira Code', 'monospace']
}
}
},
plugins: []
};
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
sveltekit(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
manifest: {
name: 'Spiceflow',
short_name: 'Spiceflow',
description: 'AI Session Orchestration - The spice must flow',
theme_color: '#f97316',
background_color: '#18181b',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 5 // 5 minutes
}
}
}
]
},
devOptions: {
enabled: true
}
})
],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
ws: true
}
}
}
});