From b3bed769ce9cc452c23db9c5efc25116853c87ec Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 13 Feb 2026 21:32:10 -0600 Subject: [PATCH 01/16] Rebuild Taskyou Pilot on SvelteKit + Cloudflare Port the original React+Go pilot web app to a modern SvelteKit application deployed on Cloudflare Workers, replacing Fly Sprites with Cloudflare Sandbox SDK for per-user isolated execution environments. Stack: - SvelteKit 5 with Svelte 5 runes ($state, $derived, $props) - Cloudflare Workers with adapter-cloudflare - Cloudflare D1 for database, KV for sessions - Cloudflare Sandbox SDK for container-based task execution - TailwindCSS with light/dark theme support Features ported from original pilot: - OAuth authentication (Google + GitHub) - Kanban board with 4 columns (Backlog, In Progress, Blocked, Done) - Task CRUD with execution in sandbox containers - Command palette (Cmd+K) for quick navigation - Project management with settings page - Task detail panel with logs and terminal access - Responsive design with status-colored indicators Co-Authored-By: Claude Opus 4.6 --- pilot/.env.example | 11 + pilot/.gitignore | 8 + pilot/Dockerfile | 23 + pilot/package.json | 33 ++ pilot/postcss.config.js | 6 + pilot/sandbox-init.sh | 9 + pilot/src/app.css | 130 ++++++ pilot/src/app.d.ts | 27 ++ pilot/src/app.html | 13 + pilot/src/hooks.server.ts | 56 +++ pilot/src/lib/api/client.ts | 104 +++++ .../src/lib/components/CommandPalette.svelte | 150 +++++++ pilot/src/lib/components/Dashboard.svelte | 155 +++++++ pilot/src/lib/components/Header.svelte | 139 ++++++ pilot/src/lib/components/LoginPage.svelte | 107 +++++ pilot/src/lib/components/NewTaskDialog.svelte | 162 +++++++ pilot/src/lib/components/ProjectDialog.svelte | 130 ++++++ pilot/src/lib/components/SettingsPage.svelte | 273 ++++++++++++ pilot/src/lib/components/TaskBoard.svelte | 113 +++++ pilot/src/lib/components/TaskCard.svelte | 126 ++++++ pilot/src/lib/components/TaskDetail.svelte | 416 ++++++++++++++++++ pilot/src/lib/server/auth.ts | 156 +++++++ pilot/src/lib/server/db.ts | 356 +++++++++++++++ pilot/src/lib/server/sandbox.ts | 123 ++++++ pilot/src/lib/stores/auth.ts | 27 ++ pilot/src/lib/stores/tasks.ts | 92 ++++ pilot/src/lib/types.ts | 93 ++++ pilot/src/routes/+layout.svelte | 7 + pilot/src/routes/+page.svelte | 46 ++ pilot/src/routes/api/auth/+server.ts | 20 + pilot/src/routes/api/auth/github/+server.ts | 41 ++ pilot/src/routes/api/auth/google/+server.ts | 45 ++ pilot/src/routes/api/projects/+server.ts | 25 ++ pilot/src/routes/api/projects/[id]/+server.ts | 32 ++ .../routes/api/sandbox/terminal/+server.ts | 13 + pilot/src/routes/api/settings/+server.ts | 21 + pilot/src/routes/api/tasks/+server.ts | 40 ++ pilot/src/routes/api/tasks/[id]/+server.ts | 46 ++ .../routes/api/tasks/[id]/close/+server.ts | 19 + .../src/routes/api/tasks/[id]/logs/+server.ts | 14 + .../routes/api/tasks/[id]/queue/+server.ts | 52 +++ .../routes/api/tasks/[id]/retry/+server.ts | 27 ++ pilot/svelte.config.js | 12 + pilot/tailwind.config.js | 46 ++ pilot/tsconfig.json | 14 + pilot/vite.config.ts | 15 + pilot/wrangler.jsonc | 48 ++ 47 files changed, 3621 insertions(+) create mode 100644 pilot/.env.example create mode 100644 pilot/.gitignore create mode 100644 pilot/Dockerfile create mode 100644 pilot/package.json create mode 100644 pilot/postcss.config.js create mode 100644 pilot/sandbox-init.sh create mode 100644 pilot/src/app.css create mode 100644 pilot/src/app.d.ts create mode 100644 pilot/src/app.html create mode 100644 pilot/src/hooks.server.ts create mode 100644 pilot/src/lib/api/client.ts create mode 100644 pilot/src/lib/components/CommandPalette.svelte create mode 100644 pilot/src/lib/components/Dashboard.svelte create mode 100644 pilot/src/lib/components/Header.svelte create mode 100644 pilot/src/lib/components/LoginPage.svelte create mode 100644 pilot/src/lib/components/NewTaskDialog.svelte create mode 100644 pilot/src/lib/components/ProjectDialog.svelte create mode 100644 pilot/src/lib/components/SettingsPage.svelte create mode 100644 pilot/src/lib/components/TaskBoard.svelte create mode 100644 pilot/src/lib/components/TaskCard.svelte create mode 100644 pilot/src/lib/components/TaskDetail.svelte create mode 100644 pilot/src/lib/server/auth.ts create mode 100644 pilot/src/lib/server/db.ts create mode 100644 pilot/src/lib/server/sandbox.ts create mode 100644 pilot/src/lib/stores/auth.ts create mode 100644 pilot/src/lib/stores/tasks.ts create mode 100644 pilot/src/lib/types.ts create mode 100644 pilot/src/routes/+layout.svelte create mode 100644 pilot/src/routes/+page.svelte create mode 100644 pilot/src/routes/api/auth/+server.ts create mode 100644 pilot/src/routes/api/auth/github/+server.ts create mode 100644 pilot/src/routes/api/auth/google/+server.ts create mode 100644 pilot/src/routes/api/projects/+server.ts create mode 100644 pilot/src/routes/api/projects/[id]/+server.ts create mode 100644 pilot/src/routes/api/sandbox/terminal/+server.ts create mode 100644 pilot/src/routes/api/settings/+server.ts create mode 100644 pilot/src/routes/api/tasks/+server.ts create mode 100644 pilot/src/routes/api/tasks/[id]/+server.ts create mode 100644 pilot/src/routes/api/tasks/[id]/close/+server.ts create mode 100644 pilot/src/routes/api/tasks/[id]/logs/+server.ts create mode 100644 pilot/src/routes/api/tasks/[id]/queue/+server.ts create mode 100644 pilot/src/routes/api/tasks/[id]/retry/+server.ts create mode 100644 pilot/svelte.config.js create mode 100644 pilot/tailwind.config.js create mode 100644 pilot/tsconfig.json create mode 100644 pilot/vite.config.ts create mode 100644 pilot/wrangler.jsonc diff --git a/pilot/.env.example b/pilot/.env.example new file mode 100644 index 00000000..98ad5e05 --- /dev/null +++ b/pilot/.env.example @@ -0,0 +1,11 @@ +# OAuth providers +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Session encryption +SESSION_SECRET= + +# Environment +ENVIRONMENT=development diff --git a/pilot/.gitignore b/pilot/.gitignore new file mode 100644 index 00000000..a552898f --- /dev/null +++ b/pilot/.gitignore @@ -0,0 +1,8 @@ +node_modules +.svelte-kit +build +.wrangler +.env +.env.* +!.env.example +package-lock.json diff --git a/pilot/Dockerfile b/pilot/Dockerfile new file mode 100644 index 00000000..a42b5029 --- /dev/null +++ b/pilot/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-slim + +RUN apt-get update && apt-get install -y \ + git \ + curl \ + python3 \ + python3-pip \ + tmux \ + sqlite3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +# Create a non-root user for running tasks +RUN useradd -m -s /bin/bash taskyou + +# Copy the task runner script +COPY sandbox-init.sh /usr/local/bin/sandbox-init.sh +RUN chmod +x /usr/local/bin/sandbox-init.sh + +USER taskyou + +ENTRYPOINT ["/usr/local/bin/sandbox-init.sh"] diff --git a/pilot/package.json b/pilot/package.json new file mode 100644 index 00000000..90b8a011 --- /dev/null +++ b/pilot/package.json @@ -0,0 +1,33 @@ +{ + "name": "taskyou-pilot", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "wrangler dev", + "deploy": "npm run build && wrangler deploy", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250129.0", + "@sveltejs/adapter-cloudflare": "^4.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^3.4.4", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "vitest": "^2.0.0", + "wrangler": "^3.28.4" + }, + "dependencies": { + "@cloudflare/sandbox": "^0.4.0", + "lucide-svelte": "^0.460.0" + } +} diff --git a/pilot/postcss.config.js b/pilot/postcss.config.js new file mode 100644 index 00000000..7b75c83a --- /dev/null +++ b/pilot/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/pilot/sandbox-init.sh b/pilot/sandbox-init.sh new file mode 100644 index 00000000..a3b2d954 --- /dev/null +++ b/pilot/sandbox-init.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Initialization script for taskyou sandbox containers +# This runs inside each user's isolated container + +echo "TaskYou sandbox initialized" +echo "Container ready for task execution" + +# Keep the container running +exec sleep infinity diff --git a/pilot/src/app.css b/pilot/src/app.css new file mode 100644 index 00000000..bb2b709b --- /dev/null +++ b/pilot/src/app.css @@ -0,0 +1,130 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --primary: 262 83% 58%; + --primary-foreground: 0 0% 100%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 262 83% 95%; + --accent-foreground: 262 83% 40%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 262 83% 58%; + --radius: 0.75rem; + + --status-backlog: 240 5% 65%; + --status-backlog-bg: 240 5% 96%; + --status-queued: 45 93% 47%; + --status-queued-bg: 45 93% 95%; + --status-processing: 217 91% 60%; + --status-processing-bg: 217 91% 95%; + --status-blocked: 0 84% 60%; + --status-blocked-bg: 0 84% 95%; + --status-done: 142 71% 45%; + --status-done-bg: 142 71% 95%; + + --glow-primary: 0 0 20px hsla(262, 83%, 58%, 0.3); + } + + .dark { + --background: 240 10% 6%; + --foreground: 0 0% 98%; + --card: 240 10% 8%; + --card-foreground: 0 0% 98%; + --primary: 262 83% 65%; + --primary-foreground: 0 0% 100%; + --secondary: 240 4% 16%; + --secondary-foreground: 0 0% 98%; + --muted: 240 4% 16%; + --muted-foreground: 240 5% 64.9%; + --accent: 262 40% 20%; + --accent-foreground: 262 83% 75%; + --destructive: 0 62.8% 50%; + --destructive-foreground: 0 0% 98%; + --border: 240 4% 18%; + --input: 240 4% 18%; + --ring: 262 83% 65%; + + --status-backlog: 240 5% 55%; + --status-backlog-bg: 240 5% 15%; + --status-queued: 45 93% 55%; + --status-queued-bg: 45 50% 15%; + --status-processing: 217 91% 65%; + --status-processing-bg: 217 50% 15%; + --status-blocked: 0 84% 60%; + --status-blocked-bg: 0 50% 15%; + --status-done: 142 71% 50%; + --status-done-bg: 142 40% 15%; + + --glow-primary: 0 0 30px hsla(262, 83%, 65%, 0.4); + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground antialiased; + } +} + +@layer utilities { + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; + } + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + height: 6px; + } + .scrollbar-thin::-webkit-scrollbar-track { + background: transparent; + } + .scrollbar-thin::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + border-radius: 3px; + } + + .text-gradient { + background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(262, 83%, 75%) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .glow-primary { + box-shadow: var(--glow-primary); + } + + .border-status-backlog { border-left: 3px solid hsl(var(--status-backlog)); } + .border-status-queued { border-left: 3px solid hsl(var(--status-queued)); } + .border-status-processing { border-left: 3px solid hsl(var(--status-processing)); } + .border-status-blocked { border-left: 3px solid hsl(var(--status-blocked)); } + .border-status-done { border-left: 3px solid hsl(var(--status-done)); } +} + +.font-mono { + font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, Monaco, 'Courier New', monospace; +} + +:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; +} + +::selection { + background: hsl(var(--primary) / 0.3); + color: inherit; +} diff --git a/pilot/src/app.d.ts b/pilot/src/app.d.ts new file mode 100644 index 00000000..a2a8d8d7 --- /dev/null +++ b/pilot/src/app.d.ts @@ -0,0 +1,27 @@ +/// +/// + +declare global { + namespace App { + interface Locals { + user: import('$lib/types').User | null; + sessionId: string | null; + } + interface Platform { + env: { + DB: D1Database; + SANDBOX: DurableObjectNamespace; + SESSIONS: KVNamespace; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; + GITHUB_CLIENT_ID?: string; + GITHUB_CLIENT_SECRET?: string; + SESSION_SECRET?: string; + ENVIRONMENT?: string; + }; + context: ExecutionContext; + } + } +} + +export {}; diff --git a/pilot/src/app.html b/pilot/src/app.html new file mode 100644 index 00000000..b42122b8 --- /dev/null +++ b/pilot/src/app.html @@ -0,0 +1,13 @@ + + + + + + + TaskYou + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/pilot/src/hooks.server.ts b/pilot/src/hooks.server.ts new file mode 100644 index 00000000..d791a3b7 --- /dev/null +++ b/pilot/src/hooks.server.ts @@ -0,0 +1,56 @@ +import type { Handle } from '@sveltejs/kit'; +import { getSessionUserId } from '$lib/server/auth'; +import { getUserById, initHostDB } from '$lib/server/db'; + +let dbInitialized = false; + +export const handle: Handle = async ({ event, resolve }) => { + const platform = event.platform; + + // Initialize DB on first request + if (platform?.env?.DB && !dbInitialized) { + try { + await initHostDB(platform.env.DB); + dbInitialized = true; + } catch (e) { + // Table already exists is fine + dbInitialized = true; + } + } + + // Dev mode: auto-authenticate with mock user + if (platform?.env?.ENVIRONMENT === 'development' || !platform?.env?.DB) { + event.locals.user = { + id: 'dev-user', + email: 'dev@localhost', + name: 'Development User', + avatar_url: '', + }; + event.locals.sessionId = 'dev-session'; + return resolve(event); + } + + // Extract session from cookie + const sessionId = event.cookies.get('session'); + if (sessionId && platform?.env?.SESSIONS && platform?.env?.DB) { + const userId = await getSessionUserId(platform.env.SESSIONS, sessionId); + if (userId) { + const user = await getUserById(platform.env.DB, userId); + if (user) { + event.locals.user = user; + event.locals.sessionId = sessionId; + } + } + } + + // Protect API routes (except auth endpoints) + const path = event.url.pathname; + if (path.startsWith('/api/') && !path.startsWith('/api/auth/') && !event.locals.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return resolve(event); +}; diff --git a/pilot/src/lib/api/client.ts b/pilot/src/lib/api/client.ts new file mode 100644 index 00000000..f3e72e59 --- /dev/null +++ b/pilot/src/lib/api/client.ts @@ -0,0 +1,104 @@ +import type { + User, + Task, + CreateTaskRequest, + UpdateTaskRequest, + Project, + CreateProjectRequest, + UpdateProjectRequest, + TaskLog, +} from '$lib/types'; + +async function fetchJSON(path: string, options?: RequestInit): Promise { + const response = await fetch(`/api${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + credentials: 'include', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Request failed'); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json(); +} + +// Auth API +export const auth = { + getMe: () => fetchJSON('/auth'), + logout: () => fetchJSON<{ success: boolean }>('/auth', { method: 'POST' }), +}; + +// Tasks API +export const tasks = { + list: (options?: { status?: string; project?: string; type?: string; all?: boolean }) => { + const params = new URLSearchParams(); + if (options?.status) params.set('status', options.status); + if (options?.project) params.set('project', options.project); + if (options?.type) params.set('type', options.type); + if (options?.all) params.set('all', 'true'); + const query = params.toString(); + return fetchJSON(`/tasks${query ? `?${query}` : ''}`); + }, + get: (id: number) => fetchJSON(`/tasks/${id}`), + create: (data: CreateTaskRequest) => + fetchJSON('/tasks', { + method: 'POST', + body: JSON.stringify(data), + }), + update: (id: number, data: UpdateTaskRequest) => + fetchJSON(`/tasks/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: number) => + fetchJSON(`/tasks/${id}`, { method: 'DELETE' }), + queue: (id: number) => + fetchJSON(`/tasks/${id}/queue`, { method: 'POST' }), + retry: (id: number, feedback?: string) => + fetchJSON(`/tasks/${id}/retry`, { + method: 'POST', + body: JSON.stringify({ feedback }), + }), + close: (id: number) => + fetchJSON(`/tasks/${id}/close`, { method: 'POST' }), + getLogs: (id: number, limit?: number) => { + const params = limit ? `?limit=${limit}` : ''; + return fetchJSON(`/tasks/${id}/logs${params}`); + }, +}; + +// Projects API +export const projects = { + list: () => fetchJSON('/projects'), + create: (data: CreateProjectRequest) => + fetchJSON('/projects', { + method: 'POST', + body: JSON.stringify(data), + }), + update: (id: number, data: UpdateProjectRequest) => + fetchJSON(`/projects/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: number) => + fetchJSON(`/projects/${id}`, { method: 'DELETE' }), +}; + +// Settings API +export const settings = { + get: () => fetchJSON>('/settings'), + update: (data: Record) => + fetchJSON<{ success: boolean }>('/settings', { + method: 'PUT', + body: JSON.stringify(data), + }), +}; diff --git a/pilot/src/lib/components/CommandPalette.svelte b/pilot/src/lib/components/CommandPalette.svelte new file mode 100644 index 00000000..11eeb9c6 --- /dev/null +++ b/pilot/src/lib/components/CommandPalette.svelte @@ -0,0 +1,150 @@ + + +{#if isOpen} + + +
+
+ +
+ +
+ + + esc +
+ + +
+ {#if items.length === 0} +
+ No results found +
+ {:else} + {#each items as item, i} + +
handleSelect(item)} + onmouseenter={() => (selectedIndex = i)} + > + {#if item.type === 'action'} + {#if item.id === 'new-task'} + + {:else} + + {/if} + {:else if item.task} + {@const si = statusIcons[item.task.status] || statusIcons.backlog} + + {/if} +
+
{item.label}
+ {#if item.description} +
{item.description}
+ {/if} +
+ {#if i === selectedIndex} + + {/if} +
+ {/each} + {/if} +
+
+
+{/if} diff --git a/pilot/src/lib/components/Dashboard.svelte b/pilot/src/lib/components/Dashboard.svelte new file mode 100644 index 00000000..d17a2e5e --- /dev/null +++ b/pilot/src/lib/components/Dashboard.svelte @@ -0,0 +1,155 @@ + + + + +
+
(showCommandPalette = true)} + /> + +
+ (showNewTask = true)} + /> +
+ + + + + (showCommandPalette = false)} + tasks={$tasks} + onSelectTask={(task) => (selectedTask = task)} + onNewTask={() => (showNewTask = true)} + {onSettings} + /> + + {#if showNewTask} + (showNewTask = false)} + /> + {/if} + + {#if selectedTask} + (selectedTask = null)} + onUpdate={handleTaskUpdate} + onDelete={() => (selectedTask = null)} + /> + {/if} +
diff --git a/pilot/src/lib/components/Header.svelte b/pilot/src/lib/components/Header.svelte new file mode 100644 index 00000000..8db25091 --- /dev/null +++ b/pilot/src/lib/components/Header.svelte @@ -0,0 +1,139 @@ + + +
+
+
+ +
+
+
+ +
+ + taskyou + +
+
+ + + + + +
+ + + + + + + + + + +
+ + + {#if showUserMenu} + + +
(showUserMenu = false)}>
+
+
+

{user.name}

+

{user.email}

+
+ + +
+ {/if} +
+
+
+
+
diff --git a/pilot/src/lib/components/LoginPage.svelte b/pilot/src/lib/components/LoginPage.svelte new file mode 100644 index 00000000..205d50ef --- /dev/null +++ b/pilot/src/lib/components/LoginPage.svelte @@ -0,0 +1,107 @@ + + +
+ + + + +
+
+ +
+
+ +
+ + taskyou + +
+ +
+

Welcome

+

+ Sign in to start executing tasks +

+
+ + + +

+ By signing in, you agree to our + Terms of Service + and + Privacy Policy +

+
+
+
diff --git a/pilot/src/lib/components/NewTaskDialog.svelte b/pilot/src/lib/components/NewTaskDialog.svelte new file mode 100644 index 00000000..8061d76d --- /dev/null +++ b/pilot/src/lib/components/NewTaskDialog.svelte @@ -0,0 +1,162 @@ + + + + +
+ +
+ + +
+
+ +
+
+
+ +
+
+

New Task

+

What would you like AI to do?

+
+
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+ {#each taskTypes as t} + + {/each} +
+
+ + +
+ + +
+
+ + +
+ + +
+
+
+
diff --git a/pilot/src/lib/components/ProjectDialog.svelte b/pilot/src/lib/components/ProjectDialog.svelte new file mode 100644 index 00000000..7f2103b4 --- /dev/null +++ b/pilot/src/lib/components/ProjectDialog.svelte @@ -0,0 +1,130 @@ + + + + +
+
+ +
+
+
+

{isEditing ? 'Edit Project' : 'New Project'}

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {#if isEditing && onDelete} + + {:else} +
+ {/if} +
+ + +
+
+
+
+
diff --git a/pilot/src/lib/components/SettingsPage.svelte b/pilot/src/lib/components/SettingsPage.svelte new file mode 100644 index 00000000..20470130 --- /dev/null +++ b/pilot/src/lib/components/SettingsPage.svelte @@ -0,0 +1,273 @@ + + + + +{#if loading} +
+
+
+

Loading settings...

+
+
+{:else} +
+
+ +
+ +
+

Settings

+

Customize your taskyou experience

+
+
+ +
+ +
+
+
+
+ +
+
+

Appearance

+

Customize how taskyou looks

+
+
+
+ +
+ {#each ['light', 'dark', 'system'] as theme} + {@const isActive = settings.theme === theme || (!settings.theme && theme === 'system')} + + {/each} +
+
+
+
+ + +
+
+
+
+ +
+
+

Defaults

+

Default values for new tasks

+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
+

Projects

+

Manage your projects

+
+
+ +
+ + {#if projects.length === 0} +
+ +

No projects yet

+ +
+ {:else} +
+ {#each projects as project} +
+
+
+
+
{project.name}
+
{project.path}
+
+
+ +
+ {/each} +
+ {/if} +
+
+ + +
+
+
+
+ +
+
+

Data

+

Export or manage your data

+
+
+

+ Data export is available through the API. +

+
+
+ + +
+ +
+
+
+
+{/if} + +{#if showNewProject} + (showNewProject = false)} + /> +{/if} + +{#if editingProject} + (editingProject = null)} + /> +{/if} diff --git a/pilot/src/lib/components/TaskBoard.svelte b/pilot/src/lib/components/TaskBoard.svelte new file mode 100644 index 00000000..e8586adb --- /dev/null +++ b/pilot/src/lib/components/TaskBoard.svelte @@ -0,0 +1,113 @@ + + +
+ +
+
+

Tasks

+
+ {#if $inProgressTasks.length > 0} + + + {$inProgressTasks.length} running + + {/if} + {#if $blockedTasks.length > 0} + + + {$blockedTasks.length} blocked + + {/if} +
+
+ +
+ + +
+ + {@render column('Backlog', Inbox, 'No tasks waiting', 'hsl(var(--status-backlog))', $backlogTasks, true)} + + + {@render column('In Progress', Zap, 'Nothing running', 'hsl(var(--status-processing))', $inProgressTasks, false)} + + + {@render column('Needs Attention', AlertCircle, 'All clear!', 'hsl(var(--status-blocked))', $blockedTasks, false)} + + + {@render column('Completed', CheckCircle, 'Nothing completed yet', 'hsl(var(--status-done))', $doneTasks, false)} +
+
+ +{#snippet column(title: string, Icon: typeof Inbox, emptyMessage: string, accentColor: string, columnTasks: Task[], showAdd: boolean)} +
+ +
0 ? accentColor : undefined} + style:border-bottom-width={columnTasks.length > 0 ? '2px' : '1px'} + > +
+ +

{title}

+
+ + {columnTasks.length} + +
+ + +
+ {#each columnTasks as task (task.id)} + + {/each} + + {#if columnTasks.length === 0} +
+ +

{emptyMessage}

+
+ {/if} +
+ + + {#if showAdd} +
+ +
+ {/if} +
+{/snippet} diff --git a/pilot/src/lib/components/TaskCard.svelte b/pilot/src/lib/components/TaskCard.svelte new file mode 100644 index 00000000..8c61175f --- /dev/null +++ b/pilot/src/lib/components/TaskCard.svelte @@ -0,0 +1,126 @@ + + + + +
onClick(task)} +> + +
+
+ + {#if task.status === 'processing'} + + {:else if task.status === 'blocked'} + + {:else if task.status === 'done'} + + {:else} + + {/if} + + {config.label} +
+ +
+ + +

+ {task.title} +

+ + + {#if task.body} +

{task.body}

+ {/if} + + +
+ {#if task.project && task.project !== 'personal'} + {task.project} + {/if} + {#if task.type} + {task.type} + {/if} +
+ + +
+ {#if task.status === 'backlog'} + + {/if} + {#if task.status === 'blocked'} + + {/if} + {#if task.status === 'processing' || task.status === 'blocked'} + + {/if} +
+
diff --git a/pilot/src/lib/components/TaskDetail.svelte b/pilot/src/lib/components/TaskDetail.svelte new file mode 100644 index 00000000..39324e55 --- /dev/null +++ b/pilot/src/lib/components/TaskDetail.svelte @@ -0,0 +1,416 @@ + + + + +
+ +
+ + +
+ +
+
+ {#if isEditing} + + {:else} +

{task.title}

+ {/if} + +
+ + {#if task.status === 'processing'} + + {:else if task.status === 'blocked'} + + {:else if task.status === 'done'} + + {:else} + + {/if} + {config.label} + + {#if task.project && task.project !== 'personal'} + {task.project} + {/if} + {#if task.type} + {task.type} + {/if} + {#if task.dangerous_mode} + + + Dangerous + + {/if} +
+
+ +
+ {#if !isEditing} + + {/if} + +
+
+ + +
+ +
+ {#if isEditing} + +
+ + +
+ {:else if task.body} +
{task.body}
+ {:else} +

No description

+ {/if} +
+ + +
+
+
+ + Created {formatDate(task.created_at)} +
+ {#if task.started_at} +
+ + + {task.completed_at + ? `Took ${formatDuration(task.started_at, task.completed_at)}` + : `Running for ${formatDuration(task.started_at)}` + } + +
+ {/if} + {#if task.branch_name} +
+ + {task.branch_name} +
+ {/if} + {#if task.pr_url} + + {/if} +
+
+ + +
+
+ {#if task.status === 'backlog'} + + {/if} + + {#if task.status === 'blocked'} + + {/if} + + {#if task.status === 'done'} + + {/if} + + {#if task.status === 'processing' || task.status === 'blocked' || task.status === 'queued'} + + {/if} + + +
+
+ + +
+ + + {#if logsExpanded} +
+ {#if loading} +
Loading logs...
+ {:else if logs.length === 0} +
No logs yet. Run the task to see output.
+ {:else} + {#each logs as log} +
+ {log.content} +
+ {/each} + {/if} +
+
+ {/if} +
+
+ + + {#if showRetryDialog} +
+
+
+ +

Add Feedback

+
+

+ Provide additional context or instructions for the AI to consider when retrying this task. +

+ +
+ + +
+
+
+ {/if} +
+
diff --git a/pilot/src/lib/server/auth.ts b/pilot/src/lib/server/auth.ts new file mode 100644 index 00000000..9c01591d --- /dev/null +++ b/pilot/src/lib/server/auth.ts @@ -0,0 +1,156 @@ +import { findOrCreateUser } from './db'; + +// Session management using KV +const SESSION_TTL = 60 * 60 * 24 * 30; // 30 days + +export async function createSession( + kv: KVNamespace, + userId: string, +): Promise { + const sessionId = crypto.randomUUID(); + await kv.put(`session:${sessionId}`, userId, { expirationTtl: SESSION_TTL }); + return sessionId; +} + +export async function getSessionUserId( + kv: KVNamespace, + sessionId: string, +): Promise { + return kv.get(`session:${sessionId}`); +} + +export async function deleteSession(kv: KVNamespace, sessionId: string): Promise { + await kv.delete(`session:${sessionId}`); +} + +// OAuth helpers +interface GoogleUserInfo { + id: string; + email: string; + name: string; + picture: string; +} + +interface GitHubUserInfo { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; +} + +export async function exchangeGoogleCode( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise { + // Exchange code for token + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!tokenRes.ok) throw new Error('Failed to exchange Google OAuth code'); + const tokenData = (await tokenRes.json()) as { access_token: string }; + + // Get user info + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + + if (!userRes.ok) throw new Error('Failed to get Google user info'); + return userRes.json() as Promise; +} + +export async function exchangeGitHubCode( + code: string, + clientId: string, + clientSecret: string, +): Promise { + // Exchange code for token + const tokenRes = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + }), + }); + + if (!tokenRes.ok) throw new Error('Failed to exchange GitHub OAuth code'); + const tokenData = (await tokenRes.json()) as { access_token: string }; + + // Get user info + const userRes = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + 'User-Agent': 'TaskYou-Pilot', + }, + }); + + if (!userRes.ok) throw new Error('Failed to get GitHub user info'); + const user = (await userRes.json()) as GitHubUserInfo; + + // Get primary email if not public + if (!user.email) { + const emailRes = await fetch('https://api.github.com/user/emails', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + 'User-Agent': 'TaskYou-Pilot', + }, + }); + if (emailRes.ok) { + const emails = (await emailRes.json()) as Array<{ email: string; primary: boolean }>; + const primary = emails.find((e) => e.primary); + if (primary) user.email = primary.email; + } + } + + return user; +} + +export async function handleGoogleCallback( + db: D1Database, + kv: KVNamespace, + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise<{ sessionId: string; user: { id: string; email: string; name: string; avatar_url: string } }> { + const googleUser = await exchangeGoogleCode(code, clientId, clientSecret, redirectUri); + const user = await findOrCreateUser(db, 'google', googleUser.id, googleUser.email, googleUser.name, googleUser.picture); + const sessionId = await createSession(kv, user.id); + return { sessionId, user }; +} + +export async function handleGitHubCallback( + db: D1Database, + kv: KVNamespace, + code: string, + clientId: string, + clientSecret: string, +): Promise<{ sessionId: string; user: { id: string; email: string; name: string; avatar_url: string } }> { + const ghUser = await exchangeGitHubCode(code, clientId, clientSecret); + const user = await findOrCreateUser( + db, + 'github', + String(ghUser.id), + ghUser.email || `${ghUser.login}@github`, + ghUser.name || ghUser.login, + ghUser.avatar_url, + ); + const sessionId = await createSession(kv, user.id); + return { sessionId, user }; +} diff --git a/pilot/src/lib/server/db.ts b/pilot/src/lib/server/db.ts new file mode 100644 index 00000000..af82c92d --- /dev/null +++ b/pilot/src/lib/server/db.ts @@ -0,0 +1,356 @@ +import type { Task, Project, TaskLog, TaskStatus } from '$lib/types'; + +// D1 database operations for the host (user accounts, sessions) +// Each user's task data lives in their sandbox's SQLite + +export async function initHostDB(db: D1Database): Promise { + await db.batch([ + db.prepare(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + sandbox_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'backlog', + type TEXT NOT NULL DEFAULT 'code', + project TEXT NOT NULL DEFAULT 'personal', + worktree_path TEXT, + branch_name TEXT, + port INTEGER, + pr_url TEXT, + pr_number INTEGER, + dangerous_mode INTEGER NOT NULL DEFAULT 0, + scheduled_at TEXT, + recurrence TEXT, + last_run_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '', + aliases TEXT NOT NULL DEFAULT '', + instructions TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#888888', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS task_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + line_type TEXT NOT NULL DEFAULT 'text', + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS settings ( + user_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `), + ]); +} + +// User operations +export async function findOrCreateUser( + db: D1Database, + provider: string, + providerId: string, + email: string, + name: string, + avatarUrl: string, +): Promise<{ id: string; email: string; name: string; avatar_url: string }> { + const id = `${provider}:${providerId}`; + + const existing = await db + .prepare('SELECT id, email, name, avatar_url FROM users WHERE id = ?') + .bind(id) + .first<{ id: string; email: string; name: string; avatar_url: string }>(); + + if (existing) { + await db + .prepare('UPDATE users SET name = ?, avatar_url = ?, updated_at = datetime(\'now\') WHERE id = ?') + .bind(name, avatarUrl, id) + .run(); + return { ...existing, name, avatar_url: avatarUrl }; + } + + await db + .prepare('INSERT INTO users (id, email, name, avatar_url, provider, provider_id) VALUES (?, ?, ?, ?, ?, ?)') + .bind(id, email, name, avatarUrl, provider, providerId) + .run(); + + return { id, email, name, avatar_url: avatarUrl }; +} + +export async function getUserById( + db: D1Database, + userId: string, +): Promise<{ id: string; email: string; name: string; avatar_url: string } | null> { + return db + .prepare('SELECT id, email, name, avatar_url FROM users WHERE id = ?') + .bind(userId) + .first(); +} + +// Task operations +export async function listTasks( + db: D1Database, + userId: string, + options: { status?: string; project?: string; type?: string; includeClosed?: boolean } = {}, +): Promise { + let query = 'SELECT * FROM tasks WHERE user_id = ?'; + const params: (string | number)[] = [userId]; + + if (options.status) { + query += ' AND status = ?'; + params.push(options.status); + } else if (!options.includeClosed) { + query += ' AND status != ?'; + params.push('done'); + } + + if (options.project) { + query += ' AND project = ?'; + params.push(options.project); + } + + if (options.type) { + query += ' AND type = ?'; + params.push(options.type); + } + + query += ' ORDER BY updated_at DESC'; + + const result = await db.prepare(query).bind(...params).all(); + return (result.results || []).map(rowToTask); +} + +export async function getTask(db: D1Database, userId: string, taskId: number): Promise { + const row = await db + .prepare('SELECT * FROM tasks WHERE id = ? AND user_id = ?') + .bind(taskId, userId) + .first(); + return row ? rowToTask(row) : null; +} + +export async function createTask( + db: D1Database, + userId: string, + data: { title: string; body?: string; type?: string; project?: string }, +): Promise { + const result = await db + .prepare( + 'INSERT INTO tasks (user_id, title, body, type, project) VALUES (?, ?, ?, ?, ?) RETURNING *', + ) + .bind(userId, data.title, data.body || '', data.type || 'code', data.project || 'personal') + .first(); + + return rowToTask(result!); +} + +export async function updateTask( + db: D1Database, + userId: string, + taskId: number, + data: { title?: string; body?: string; status?: TaskStatus; type?: string; project?: string }, +): Promise { + const sets: string[] = []; + const params: (string | number)[] = []; + + if (data.title !== undefined) { sets.push('title = ?'); params.push(data.title); } + if (data.body !== undefined) { sets.push('body = ?'); params.push(data.body); } + if (data.status !== undefined) { sets.push('status = ?'); params.push(data.status); } + if (data.type !== undefined) { sets.push('type = ?'); params.push(data.type); } + if (data.project !== undefined) { sets.push('project = ?'); params.push(data.project); } + + if (sets.length === 0) return getTask(db, userId, taskId); + + sets.push("updated_at = datetime('now')"); + + // Track status transitions + if (data.status === 'processing' || data.status === 'queued') { + sets.push("started_at = COALESCE(started_at, datetime('now'))"); + } + if (data.status === 'done') { + sets.push("completed_at = datetime('now')"); + } + + params.push(taskId, userId); + + const row = await db + .prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ? AND user_id = ? RETURNING *`) + .bind(...params) + .first(); + + return row ? rowToTask(row) : null; +} + +export async function deleteTask(db: D1Database, userId: string, taskId: number): Promise { + const result = await db + .prepare('DELETE FROM tasks WHERE id = ? AND user_id = ?') + .bind(taskId, userId) + .run(); + return (result.meta?.changes ?? 0) > 0; +} + +// Project operations +export async function listProjects(db: D1Database, userId: string): Promise { + const result = await db + .prepare('SELECT * FROM projects WHERE user_id = ? ORDER BY name') + .bind(userId) + .all(); + return (result.results || []).map(({ user_id, ...p }) => p as Project); +} + +export async function createProject( + db: D1Database, + userId: string, + data: { name: string; path: string; aliases?: string; instructions?: string; color?: string }, +): Promise { + const result = await db + .prepare( + 'INSERT INTO projects (user_id, name, path, aliases, instructions, color) VALUES (?, ?, ?, ?, ?, ?) RETURNING *', + ) + .bind(userId, data.name, data.path, data.aliases || '', data.instructions || '', data.color || '#888888') + .first(); + + const { user_id, ...project } = result!; + return project as Project; +} + +export async function updateProject( + db: D1Database, + userId: string, + projectId: number, + data: { name?: string; path?: string; aliases?: string; instructions?: string; color?: string }, +): Promise { + const sets: string[] = []; + const params: (string | number)[] = []; + + if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); } + if (data.path !== undefined) { sets.push('path = ?'); params.push(data.path); } + if (data.aliases !== undefined) { sets.push('aliases = ?'); params.push(data.aliases); } + if (data.instructions !== undefined) { sets.push('instructions = ?'); params.push(data.instructions); } + if (data.color !== undefined) { sets.push('color = ?'); params.push(data.color); } + + if (sets.length === 0) return null; + + params.push(projectId, userId); + + const row = await db + .prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ? AND user_id = ? RETURNING *`) + .bind(...params) + .first(); + + if (!row) return null; + const { user_id, ...project } = row; + return project as Project; +} + +export async function deleteProject(db: D1Database, userId: string, projectId: number): Promise { + const result = await db + .prepare('DELETE FROM projects WHERE id = ? AND user_id = ?') + .bind(projectId, userId) + .run(); + return (result.meta?.changes ?? 0) > 0; +} + +// Task logs +export async function getTaskLogs( + db: D1Database, + userId: string, + taskId: number, + limit = 200, +): Promise { + const result = await db + .prepare( + `SELECT tl.* FROM task_logs tl + JOIN tasks t ON t.id = tl.task_id + WHERE tl.task_id = ? AND t.user_id = ? + ORDER BY tl.id DESC LIMIT ?`, + ) + .bind(taskId, userId, limit) + .all(); + return result.results || []; +} + +export async function addTaskLog( + db: D1Database, + taskId: number, + lineType: string, + content: string, +): Promise { + const result = await db + .prepare( + 'INSERT INTO task_logs (task_id, line_type, content) VALUES (?, ?, ?) RETURNING *', + ) + .bind(taskId, lineType, content) + .first(); + return result!; +} + +// Settings +export async function getSettings(db: D1Database, userId: string): Promise> { + const result = await db + .prepare('SELECT key, value FROM settings WHERE user_id = ?') + .bind(userId) + .all<{ key: string; value: string }>(); + + const settings: Record = {}; + for (const row of result.results || []) { + settings[row.key] = row.value; + } + return settings; +} + +export async function updateSettings( + db: D1Database, + userId: string, + data: Record, +): Promise { + const stmts = Object.entries(data).map(([key, value]) => + db + .prepare('INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, ?, ?)') + .bind(userId, key, value), + ); + if (stmts.length > 0) { + await db.batch(stmts); + } +} + +// Helper to convert DB row to Task +function rowToTask(row: Task & { user_id?: string; dangerous_mode: number | boolean }): Task { + const { user_id, ...task } = row as Task & { user_id?: string }; + return { + ...task, + dangerous_mode: Boolean(task.dangerous_mode), + }; +} diff --git a/pilot/src/lib/server/sandbox.ts b/pilot/src/lib/server/sandbox.ts new file mode 100644 index 00000000..6d1ed4c6 --- /dev/null +++ b/pilot/src/lib/server/sandbox.ts @@ -0,0 +1,123 @@ +// Cloudflare Sandbox SDK integration +// Each user gets their own isolated sandbox container for task execution +// +// This replaces the Fly Sprites integration from the original pilot. +// Instead of Fly VMs, we use Cloudflare Containers via the Sandbox SDK. +// +// Note: @cloudflare/sandbox is a Cloudflare Workers runtime package. +// It's imported dynamically to avoid build issues with Node.js SSR. + +export interface SandboxInfo { + id: string; + status: 'pending' | 'creating' | 'running' | 'stopped' | 'error'; +} + +async function loadSandboxSDK() { + // Dynamic import to avoid SSR build issues - this only runs in Cloudflare Workers runtime + const { getSandbox } = await import('@cloudflare/sandbox'); + return { getSandbox }; +} + +/** + * Get or create a sandbox for a user. + * Each user gets a dedicated sandbox identified by their user ID. + */ +export async function getUserSandbox( + binding: DurableObjectNamespace, + userId: string, +) { + const { getSandbox } = await loadSandboxSDK(); + return getSandbox(binding, `user-${userId}`, { + sleepAfter: '15 minutes', + }); +} + +/** + * Execute a task inside the user's sandbox. + */ +export async function executeInSandbox( + binding: DurableObjectNamespace, + userId: string, + command: string, + options?: { cwd?: string; env?: Record }, +) { + const sandbox = await getUserSandbox(binding, userId); + return sandbox.exec(command, { + cwd: options?.cwd || '/workspace', + env: options?.env, + }); +} + +/** + * Start a long-running process in the sandbox. + */ +export async function startSandboxProcess( + binding: DurableObjectNamespace, + userId: string, + command: string, + options?: { cwd?: string; env?: Record }, +) { + const sandbox = await getUserSandbox(binding, userId); + return sandbox.startProcess(command, { + cwd: options?.cwd || '/workspace', + env: options?.env, + }); +} + +/** + * Get process logs from the sandbox. + */ +export async function getSandboxProcessLogs( + binding: DurableObjectNamespace, + userId: string, + processId: string, +) { + const sandbox = await getUserSandbox(binding, userId); + return sandbox.getProcessLogs(processId); +} + +/** + * List all active processes in the user's sandbox. + */ +export async function listSandboxProcesses( + binding: DurableObjectNamespace, + userId: string, +) { + const sandbox = await getUserSandbox(binding, userId); + return sandbox.listProcesses(); +} + +/** + * Kill a process in the sandbox. + */ +export async function killSandboxProcess( + binding: DurableObjectNamespace, + userId: string, + processId: string, +) { + const sandbox = await getUserSandbox(binding, userId); + return sandbox.killProcess(processId); +} + +/** + * Destroy a user's sandbox, releasing all resources. + */ +export async function destroySandbox( + binding: DurableObjectNamespace, + userId: string, +) { + const sandbox = await getUserSandbox(binding, userId); + return sandbox.destroy(); +} + +/** + * Handle a terminal WebSocket upgrade request for the user's sandbox. + */ +export async function handleTerminalUpgrade( + binding: DurableObjectNamespace, + userId: string, + request: Request, +) { + const sandbox = await getUserSandbox(binding, userId); + return sandbox.terminal(request); +} diff --git a/pilot/src/lib/stores/auth.ts b/pilot/src/lib/stores/auth.ts new file mode 100644 index 00000000..5f08d146 --- /dev/null +++ b/pilot/src/lib/stores/auth.ts @@ -0,0 +1,27 @@ +import { writable } from 'svelte/store'; +import type { User } from '$lib/types'; +import { auth } from '$lib/api/client'; + +export const user = writable(null); +export const loading = writable(true); + +export async function fetchUser() { + loading.set(true); + try { + const userData = await auth.getMe(); + user.set(userData); + } catch { + user.set(null); + } finally { + loading.set(false); + } +} + +export async function logout() { + try { + await auth.logout(); + } catch { + // ignore + } + user.set(null); +} diff --git a/pilot/src/lib/stores/tasks.ts b/pilot/src/lib/stores/tasks.ts new file mode 100644 index 00000000..41ac825c --- /dev/null +++ b/pilot/src/lib/stores/tasks.ts @@ -0,0 +1,92 @@ +import { writable, derived } from 'svelte/store'; +import type { Task, CreateTaskRequest, UpdateTaskRequest, TaskStatus } from '$lib/types'; +import { tasks as tasksApi } from '$lib/api/client'; + +export const tasks = writable([]); +export const tasksLoading = writable(true); + +// Derived stores for board columns +export const backlogTasks = derived(tasks, ($tasks) => + $tasks.filter((t) => t.status === 'backlog').sort(byUpdatedDesc), +); +export const inProgressTasks = derived(tasks, ($tasks) => + $tasks + .filter((t) => t.status === 'queued' || t.status === 'processing') + .sort((a, b) => { + if (a.status === 'processing' && b.status !== 'processing') return -1; + if (b.status === 'processing' && a.status !== 'processing') return 1; + return byUpdatedDesc(a, b); + }), +); +export const blockedTasks = derived(tasks, ($tasks) => + $tasks.filter((t) => t.status === 'blocked').sort(byUpdatedDesc), +); +export const doneTasks = derived(tasks, ($tasks) => + $tasks.filter((t) => t.status === 'done').sort(byUpdatedDesc), +); + +function byUpdatedDesc(a: Task, b: Task) { + return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); +} + +export async function fetchTasks() { + tasksLoading.set(true); + try { + const data = await tasksApi.list({ all: true }); + tasks.set(data); + } catch (e) { + console.error('Failed to fetch tasks:', e); + } finally { + tasksLoading.set(false); + } +} + +export async function createTask(data: CreateTaskRequest): Promise { + const task = await tasksApi.create(data); + tasks.update((prev) => [...prev, task]); + return task; +} + +export async function updateTask(id: number, data: UpdateTaskRequest): Promise { + const task = await tasksApi.update(id, data); + tasks.update((prev) => prev.map((t) => (t.id === id ? task : t))); + return task; +} + +export async function deleteTask(id: number): Promise { + await tasksApi.delete(id); + tasks.update((prev) => prev.filter((t) => t.id !== id)); +} + +export async function queueTask(id: number): Promise { + const task = await tasksApi.queue(id); + tasks.update((prev) => prev.map((t) => (t.id === id ? task : t))); + return task; +} + +export async function retryTask(id: number, feedback?: string): Promise { + const task = await tasksApi.retry(id, feedback); + tasks.update((prev) => prev.map((t) => (t.id === id ? task : t))); + return task; +} + +export async function closeTask(id: number): Promise { + const task = await tasksApi.close(id); + tasks.update((prev) => prev.map((t) => (t.id === id ? task : t))); + return task; +} + +// Periodic refresh for active tasks +let pollInterval: ReturnType | null = null; + +export function startPolling() { + stopPolling(); + pollInterval = setInterval(fetchTasks, 5000); +} + +export function stopPolling() { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } +} diff --git a/pilot/src/lib/types.ts b/pilot/src/lib/types.ts new file mode 100644 index 00000000..d4eca91f --- /dev/null +++ b/pilot/src/lib/types.ts @@ -0,0 +1,93 @@ +// User and authentication types +export interface User { + id: string; + email: string; + name: string; + avatar_url: string; + sandbox_id?: string; + sandbox_status?: SandboxStatus; +} + +export type SandboxStatus = 'pending' | 'creating' | 'running' | 'stopped' | 'error'; + +// Task types +export type TaskStatus = 'backlog' | 'queued' | 'processing' | 'blocked' | 'done'; + +export interface Task { + id: number; + title: string; + body: string; + status: TaskStatus; + type: string; + project: string; + worktree_path?: string; + branch_name?: string; + port?: number; + pr_url?: string; + pr_number?: number; + dangerous_mode: boolean; + scheduled_at?: string; + recurrence?: string; + last_run_at?: string; + created_at: string; + updated_at: string; + started_at?: string; + completed_at?: string; +} + +export interface CreateTaskRequest { + title: string; + body?: string; + type?: string; + project?: string; +} + +export interface UpdateTaskRequest { + title?: string; + body?: string; + status?: TaskStatus; + type?: string; + project?: string; +} + +// Project types +export interface Project { + id: number; + name: string; + path: string; + aliases: string; + instructions: string; + color: string; + created_at: string; +} + +export interface CreateProjectRequest { + name: string; + path: string; + aliases?: string; + instructions?: string; + color?: string; +} + +export interface UpdateProjectRequest { + name?: string; + path?: string; + aliases?: string; + instructions?: string; + color?: string; +} + +// Task log types +export interface TaskLog { + id: number; + task_id: number; + line_type: 'system' | 'text' | 'tool' | 'error' | 'output'; + content: string; + created_at: string; +} + +// WebSocket message types +export type WebSocketMessage = + | { type: 'task_update'; data: Task } + | { type: 'task_deleted'; data: { id: number } } + | { type: 'task_log'; data: { task_id: number; log: TaskLog } }; diff --git a/pilot/src/routes/+layout.svelte b/pilot/src/routes/+layout.svelte new file mode 100644 index 00000000..dcb1eb48 --- /dev/null +++ b/pilot/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/pilot/src/routes/+page.svelte b/pilot/src/routes/+page.svelte new file mode 100644 index 00000000..f6d85b22 --- /dev/null +++ b/pilot/src/routes/+page.svelte @@ -0,0 +1,46 @@ + + +{#if $loading} +
+
+
+

Loading...

+
+
+{:else if !$user} + +{:else if view === 'settings'} + (view = 'dashboard')} /> +{:else} + (view = 'settings')} + /> +{/if} diff --git a/pilot/src/routes/api/auth/+server.ts b/pilot/src/routes/api/auth/+server.ts new file mode 100644 index 00000000..49ae1fd7 --- /dev/null +++ b/pilot/src/routes/api/auth/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { deleteSession } from '$lib/server/auth'; + +// GET /api/auth - Get current user +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + return json(locals.user); +}; + +// POST /api/auth/logout +export const POST: RequestHandler = async ({ locals, cookies, platform }) => { + if (locals.sessionId && platform?.env?.SESSIONS) { + await deleteSession(platform.env.SESSIONS, locals.sessionId); + } + cookies.delete('session', { path: '/' }); + return json({ success: true }); +}; diff --git a/pilot/src/routes/api/auth/github/+server.ts b/pilot/src/routes/api/auth/github/+server.ts new file mode 100644 index 00000000..7747775e --- /dev/null +++ b/pilot/src/routes/api/auth/github/+server.ts @@ -0,0 +1,41 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { handleGitHubCallback } from '$lib/server/auth'; + +// GET /api/auth/github - Start GitHub OAuth flow +export const GET: RequestHandler = async ({ url, platform, cookies }) => { + const env = platform?.env; + if (!env?.GITHUB_CLIENT_ID || !env?.GITHUB_CLIENT_SECRET) { + return new Response('GitHub OAuth not configured', { status: 500 }); + } + + const code = url.searchParams.get('code'); + + if (!code) { + // Redirect to GitHub OAuth + const authUrl = new URL('https://github.com/login/oauth/authorize'); + authUrl.searchParams.set('client_id', env.GITHUB_CLIENT_ID); + authUrl.searchParams.set('redirect_uri', `${url.origin}/api/auth/github`); + authUrl.searchParams.set('scope', 'user:email'); + throw redirect(302, authUrl.toString()); + } + + // Handle callback + const { sessionId } = await handleGitHubCallback( + env.DB, + env.SESSIONS, + code, + env.GITHUB_CLIENT_ID, + env.GITHUB_CLIENT_SECRET, + ); + + cookies.set('session', sessionId, { + path: '/', + httpOnly: true, + secure: url.protocol === 'https:', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + }); + + throw redirect(302, '/'); +}; diff --git a/pilot/src/routes/api/auth/google/+server.ts b/pilot/src/routes/api/auth/google/+server.ts new file mode 100644 index 00000000..fd6839f9 --- /dev/null +++ b/pilot/src/routes/api/auth/google/+server.ts @@ -0,0 +1,45 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { handleGoogleCallback } from '$lib/server/auth'; + +// GET /api/auth/google - Start Google OAuth flow +export const GET: RequestHandler = async ({ url, platform, cookies }) => { + const env = platform?.env; + if (!env?.GOOGLE_CLIENT_ID || !env?.GOOGLE_CLIENT_SECRET) { + return new Response('Google OAuth not configured', { status: 500 }); + } + + const code = url.searchParams.get('code'); + const redirectUri = `${url.origin}/api/auth/google`; + + if (!code) { + // Redirect to Google OAuth + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + authUrl.searchParams.set('client_id', env.GOOGLE_CLIENT_ID); + authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', 'openid email profile'); + authUrl.searchParams.set('access_type', 'offline'); + throw redirect(302, authUrl.toString()); + } + + // Handle callback + const { sessionId } = await handleGoogleCallback( + env.DB, + env.SESSIONS, + code, + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + redirectUri, + ); + + cookies.set('session', sessionId, { + path: '/', + httpOnly: true, + secure: url.protocol === 'https:', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, // 30 days + }); + + throw redirect(302, '/'); +}; diff --git a/pilot/src/routes/api/projects/+server.ts b/pilot/src/routes/api/projects/+server.ts new file mode 100644 index 00000000..9984f5be --- /dev/null +++ b/pilot/src/routes/api/projects/+server.ts @@ -0,0 +1,25 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listProjects, createProject } from '$lib/server/db'; + +// GET /api/projects +export const GET: RequestHandler = async ({ locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const projects = await listProjects(db, user.id); + return json(projects); +}; + +// POST /api/projects +export const POST: RequestHandler = async ({ request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const data = await request.json(); + + if (!data.name) { + return json({ error: 'Name is required' }, { status: 400 }); + } + + const project = await createProject(db, user.id, data); + return json(project, { status: 201 }); +}; diff --git a/pilot/src/routes/api/projects/[id]/+server.ts b/pilot/src/routes/api/projects/[id]/+server.ts new file mode 100644 index 00000000..41c9fb9d --- /dev/null +++ b/pilot/src/routes/api/projects/[id]/+server.ts @@ -0,0 +1,32 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { updateProject, deleteProject } from '$lib/server/db'; + +// PUT /api/projects/:id +export const PUT: RequestHandler = async ({ params, request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const projectId = parseInt(params.id); + const data = await request.json(); + + const project = await updateProject(db, user.id, projectId, data); + if (!project) { + return json({ error: 'Project not found' }, { status: 404 }); + } + + return json(project); +}; + +// DELETE /api/projects/:id +export const DELETE: RequestHandler = async ({ params, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const projectId = parseInt(params.id); + + const deleted = await deleteProject(db, user.id, projectId); + if (!deleted) { + return json({ error: 'Project not found' }, { status: 404 }); + } + + return new Response(null, { status: 204 }); +}; diff --git a/pilot/src/routes/api/sandbox/terminal/+server.ts b/pilot/src/routes/api/sandbox/terminal/+server.ts new file mode 100644 index 00000000..97f4cdc5 --- /dev/null +++ b/pilot/src/routes/api/sandbox/terminal/+server.ts @@ -0,0 +1,13 @@ +import type { RequestHandler } from './$types'; +import { handleTerminalUpgrade } from '$lib/server/sandbox'; + +// GET /api/sandbox/terminal - WebSocket upgrade for terminal access +export const GET: RequestHandler = async ({ request, locals, platform }) => { + const user = locals.user!; + + if (!platform?.env?.SANDBOX) { + return new Response('Sandbox not available', { status: 503 }); + } + + return handleTerminalUpgrade(platform.env.SANDBOX, user.id, request); +}; diff --git a/pilot/src/routes/api/settings/+server.ts b/pilot/src/routes/api/settings/+server.ts new file mode 100644 index 00000000..6b881772 --- /dev/null +++ b/pilot/src/routes/api/settings/+server.ts @@ -0,0 +1,21 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getSettings, updateSettings } from '$lib/server/db'; + +// GET /api/settings +export const GET: RequestHandler = async ({ locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const settings = await getSettings(db, user.id); + return json(settings); +}; + +// PUT /api/settings +export const PUT: RequestHandler = async ({ request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const data = await request.json(); + + await updateSettings(db, user.id, data); + return json({ success: true }); +}; diff --git a/pilot/src/routes/api/tasks/+server.ts b/pilot/src/routes/api/tasks/+server.ts new file mode 100644 index 00000000..390a61c3 --- /dev/null +++ b/pilot/src/routes/api/tasks/+server.ts @@ -0,0 +1,40 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listTasks, createTask } from '$lib/server/db'; + +// GET /api/tasks +export const GET: RequestHandler = async ({ url, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + + const options = { + status: url.searchParams.get('status') || undefined, + project: url.searchParams.get('project') || undefined, + type: url.searchParams.get('type') || undefined, + includeClosed: url.searchParams.get('all') === 'true', + }; + + const tasks = await listTasks(db, user.id, options); + return json(tasks); +}; + +// POST /api/tasks +export const POST: RequestHandler = async ({ request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + + const data = await request.json(); + + if (!data.title) { + return json({ error: 'Title is required' }, { status: 400 }); + } + + const task = await createTask(db, user.id, { + title: data.title, + body: data.body, + type: data.type, + project: data.project, + }); + + return json(task, { status: 201 }); +}; diff --git a/pilot/src/routes/api/tasks/[id]/+server.ts b/pilot/src/routes/api/tasks/[id]/+server.ts new file mode 100644 index 00000000..cc6c5b99 --- /dev/null +++ b/pilot/src/routes/api/tasks/[id]/+server.ts @@ -0,0 +1,46 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getTask, updateTask, deleteTask } from '$lib/server/db'; + +// GET /api/tasks/:id +export const GET: RequestHandler = async ({ params, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + + const task = await getTask(db, user.id, taskId); + if (!task) { + return json({ error: 'Task not found' }, { status: 404 }); + } + + return json(task); +}; + +// PUT /api/tasks/:id +export const PUT: RequestHandler = async ({ params, request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + + const data = await request.json(); + const task = await updateTask(db, user.id, taskId, data); + if (!task) { + return json({ error: 'Task not found' }, { status: 404 }); + } + + return json(task); +}; + +// DELETE /api/tasks/:id +export const DELETE: RequestHandler = async ({ params, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + + const deleted = await deleteTask(db, user.id, taskId); + if (!deleted) { + return json({ error: 'Task not found' }, { status: 404 }); + } + + return new Response(null, { status: 204 }); +}; diff --git a/pilot/src/routes/api/tasks/[id]/close/+server.ts b/pilot/src/routes/api/tasks/[id]/close/+server.ts new file mode 100644 index 00000000..88859750 --- /dev/null +++ b/pilot/src/routes/api/tasks/[id]/close/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { updateTask, addTaskLog } from '$lib/server/db'; + +// POST /api/tasks/:id/close - Mark a task as done +export const POST: RequestHandler = async ({ params, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + + const task = await updateTask(db, user.id, taskId, { status: 'done' }); + if (!task) { + return json({ error: 'Task not found' }, { status: 404 }); + } + + await addTaskLog(db, taskId, 'system', 'Task marked as done'); + + return json(task); +}; diff --git a/pilot/src/routes/api/tasks/[id]/logs/+server.ts b/pilot/src/routes/api/tasks/[id]/logs/+server.ts new file mode 100644 index 00000000..95591610 --- /dev/null +++ b/pilot/src/routes/api/tasks/[id]/logs/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getTaskLogs } from '$lib/server/db'; + +// GET /api/tasks/:id/logs +export const GET: RequestHandler = async ({ params, url, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + const limit = parseInt(url.searchParams.get('limit') || '200'); + + const logs = await getTaskLogs(db, user.id, taskId, limit); + return json(logs); +}; diff --git a/pilot/src/routes/api/tasks/[id]/queue/+server.ts b/pilot/src/routes/api/tasks/[id]/queue/+server.ts new file mode 100644 index 00000000..0ffbe996 --- /dev/null +++ b/pilot/src/routes/api/tasks/[id]/queue/+server.ts @@ -0,0 +1,52 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { updateTask, addTaskLog } from '$lib/server/db'; +import { executeInSandbox } from '$lib/server/sandbox'; + +// POST /api/tasks/:id/queue - Queue a task for execution +export const POST: RequestHandler = async ({ params, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + + // Update status to queued + const task = await updateTask(db, user.id, taskId, { status: 'queued' }); + if (!task) { + return json({ error: 'Task not found' }, { status: 404 }); + } + + // Log the queue action + await addTaskLog(db, taskId, 'system', `Task queued for execution`); + + // Start execution in sandbox (fire and forget) + if (platform?.env?.SANDBOX) { + platform.context.waitUntil( + (async () => { + try { + await addTaskLog(db, taskId, 'system', 'Starting sandbox execution...'); + await updateTask(db, user.id, taskId, { status: 'processing' }); + + const result = await executeInSandbox( + platform.env.SANDBOX, + user.id, + `echo "Executing task: ${task.title}"`, + ); + + await addTaskLog(db, taskId, 'output', result.stdout || ''); + if (result.stderr) { + await addTaskLog(db, taskId, 'error', result.stderr); + } + + // Mark as blocked (needs human review) rather than auto-completing + await updateTask(db, user.id, taskId, { status: 'blocked' }); + await addTaskLog(db, taskId, 'system', 'Task execution completed, awaiting review'); + } catch (error) { + await addTaskLog(db, taskId, 'error', `Execution failed: ${error}`); + await updateTask(db, user.id, taskId, { status: 'blocked' }); + } + })(), + ); + } + + return json(task); +}; diff --git a/pilot/src/routes/api/tasks/[id]/retry/+server.ts b/pilot/src/routes/api/tasks/[id]/retry/+server.ts new file mode 100644 index 00000000..160cdd2a --- /dev/null +++ b/pilot/src/routes/api/tasks/[id]/retry/+server.ts @@ -0,0 +1,27 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { updateTask, addTaskLog } from '$lib/server/db'; + +// POST /api/tasks/:id/retry - Retry a task with optional feedback +export const POST: RequestHandler = async ({ params, request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + + const body = await request.json().catch(() => ({})); + const feedback = (body as { feedback?: string }).feedback; + + // Update status back to queued + const task = await updateTask(db, user.id, taskId, { status: 'queued' }); + if (!task) { + return json({ error: 'Task not found' }, { status: 404 }); + } + + if (feedback) { + await addTaskLog(db, taskId, 'system', `Retrying with feedback: ${feedback}`); + } else { + await addTaskLog(db, taskId, 'system', 'Retrying task'); + } + + return json(task); +}; diff --git a/pilot/svelte.config.js b/pilot/svelte.config.js new file mode 100644 index 00000000..a7823b30 --- /dev/null +++ b/pilot/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-cloudflare'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/pilot/tailwind.config.js b/pilot/tailwind.config.js new file mode 100644 index 00000000..7c6bbe55 --- /dev/null +++ b/pilot/tailwind.config.js @@ -0,0 +1,46 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{html,js,svelte,ts}'], + darkMode: 'class', + theme: { + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + }, + }, + plugins: [], +}; diff --git a/pilot/tsconfig.json b/pilot/tsconfig.json new file mode 100644 index 00000000..a8f10c8e --- /dev/null +++ b/pilot/tsconfig.json @@ -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" + } +} diff --git a/pilot/vite.config.ts b/pilot/vite.config.ts new file mode 100644 index 00000000..38b255b7 --- /dev/null +++ b/pilot/vite.config.ts @@ -0,0 +1,15 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + resolve: { + conditions: ['workerd', 'worker', 'browser'], + }, + ssr: { + resolve: { + conditions: ['workerd', 'worker', 'node'], + externalConditions: ['workerd', 'worker', 'node'], + }, + }, +}); diff --git a/pilot/wrangler.jsonc b/pilot/wrangler.jsonc new file mode 100644 index 00000000..47af728a --- /dev/null +++ b/pilot/wrangler.jsonc @@ -0,0 +1,48 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "taskyou-pilot", + "main": ".svelte-kit/cloudflare/_worker.js", + "compatibility_date": "2025-04-01", + "assets": { + "directory": ".svelte-kit/cloudflare", + "binding": "ASSETS" + }, + // Containers config requires wrangler v4+. The adapter-cloudflare currently + // pins wrangler v3, so we keep this commented out until the adapter is updated. + // When deploying with wrangler v4, uncomment and add: + // "containers": [{ + // "class_name": "TaskSandbox", + // "image": "./Dockerfile", + // "max_instances": 10 + // }], + "durable_objects": { + "bindings": [ + { + "name": "SANDBOX", + "class_name": "TaskSandbox" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["TaskSandbox"] + } + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "taskyou-pilot-db", + "database_id": "placeholder-replace-with-actual-id" + } + ], + "kv_namespaces": [ + { + "binding": "SESSIONS", + "id": "placeholder-replace-with-actual-id" + } + ], + "vars": { + "ENVIRONMENT": "production" + } +} From 171127555c3fe9b8c7b9f398899b4f0afe3b0c65 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 13 Feb 2026 21:37:52 -0600 Subject: [PATCH 02/16] Fix local dev setup for pilot - Set ENVIRONMENT=development in wrangler vars so dev mode auth bypass works - Create dev user in D1 on first request to avoid foreign key errors - Add .dev.vars to gitignore Co-Authored-By: Claude Opus 4.6 --- pilot/.gitignore | 1 + pilot/src/hooks.server.ts | 13 ++++++++++++- pilot/wrangler.jsonc | 3 ++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/pilot/.gitignore b/pilot/.gitignore index a552898f..eb1c387d 100644 --- a/pilot/.gitignore +++ b/pilot/.gitignore @@ -5,4 +5,5 @@ build .env .env.* !.env.example +.dev.vars package-lock.json diff --git a/pilot/src/hooks.server.ts b/pilot/src/hooks.server.ts index d791a3b7..715acb52 100644 --- a/pilot/src/hooks.server.ts +++ b/pilot/src/hooks.server.ts @@ -20,12 +20,23 @@ export const handle: Handle = async ({ event, resolve }) => { // Dev mode: auto-authenticate with mock user if (platform?.env?.ENVIRONMENT === 'development' || !platform?.env?.DB) { - event.locals.user = { + const devUser = { id: 'dev-user', email: 'dev@localhost', name: 'Development User', avatar_url: '', }; + // Ensure dev user exists in DB + if (platform?.env?.DB && dbInitialized) { + try { + await platform.env.DB.prepare( + `INSERT OR IGNORE INTO users (id, email, name, avatar_url, provider, provider_id) VALUES (?, ?, ?, ?, ?, ?)` + ).bind(devUser.id, devUser.email, devUser.name, devUser.avatar_url, 'dev', 'dev').run(); + } catch { + // ignore if already exists + } + } + event.locals.user = devUser; event.locals.sessionId = 'dev-session'; return resolve(event); } diff --git a/pilot/wrangler.jsonc b/pilot/wrangler.jsonc index 47af728a..b1693a21 100644 --- a/pilot/wrangler.jsonc +++ b/pilot/wrangler.jsonc @@ -43,6 +43,7 @@ } ], "vars": { - "ENVIRONMENT": "production" + // Set to "production" when deploying via Cloudflare dashboard or wrangler secret + "ENVIRONMENT": "development" } } From 43e1c88e5e5cae2d3ce09cbb83085703bb24f1df Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 13 Feb 2026 21:41:41 -0600 Subject: [PATCH 03/16] Disable SSR for pilot SPA Fixes Svelte 5 ssr_context.r error caused by store subscriptions running during server-side rendering without a component context. The pilot is a client-side SPA that requires browser APIs and auth. Co-Authored-By: Claude Opus 4.6 --- pilot/src/routes/+layout.ts | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 pilot/src/routes/+layout.ts diff --git a/pilot/src/routes/+layout.ts b/pilot/src/routes/+layout.ts new file mode 100644 index 00000000..ef995480 --- /dev/null +++ b/pilot/src/routes/+layout.ts @@ -0,0 +1,2 @@ +// Disable SSR - this is a client-side SPA that requires browser APIs and auth +export const ssr = false; From 230275d4ecc963b7779ad0b8e2a0c6a915ca3b66 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 13 Feb 2026 21:50:47 -0600 Subject: [PATCH 04/16] Migrate stores to Svelte 5 runes for proper reactivity Replace writable/derived stores with $state objects in .svelte.ts files. The old store pattern caused ssr_context errors and onMount not firing with ssr=false. Using $effect instead of onMount for initialization resolves the client-side hydration issue. Co-Authored-By: Claude Opus 4.6 --- pilot/src/lib/components/Dashboard.svelte | 8 +-- pilot/src/lib/components/TaskBoard.svelte | 18 +++--- pilot/src/lib/stores/auth.svelte.ts | 27 ++++++++ pilot/src/lib/stores/auth.ts | 27 -------- .../lib/stores/{tasks.ts => tasks.svelte.ts} | 64 ++++++++++--------- pilot/src/routes/+page.svelte | 37 ++++++----- 6 files changed, 96 insertions(+), 85 deletions(-) create mode 100644 pilot/src/lib/stores/auth.svelte.ts delete mode 100644 pilot/src/lib/stores/auth.ts rename pilot/src/lib/stores/{tasks.ts => tasks.svelte.ts} (58%) diff --git a/pilot/src/lib/components/Dashboard.svelte b/pilot/src/lib/components/Dashboard.svelte index d17a2e5e..78a4e8ef 100644 --- a/pilot/src/lib/components/Dashboard.svelte +++ b/pilot/src/lib/components/Dashboard.svelte @@ -1,11 +1,11 @@ -{#if $loading} +{#if authState.loading}

Loading...

-{:else if !$user} +{:else if !authState.user} {:else if view === 'settings'} (view = 'dashboard')} /> {:else} (view = 'settings')} /> From 4d3a62d5eb20c82fc3a2c7b57a7f075d5bd1e402 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 14 Feb 2026 09:05:05 -0600 Subject: [PATCH 05/16] Rebuild pilot UI: Tailwind v4 + basecoat, sidebar, kanban, chat, keyboard shortcuts Migrate from Tailwind v3 to Tailwind v4 with basecoat-css component library. Major UI overhaul matching the Rails reference implementation: - Basecoat sidebar with collapsible state, chat list, nav items - Kanban board with vim-style navigation (hjkl/arrows), multi-select (x), bulk actions (run/delete/done), drag-and-drop, context menus - Task cards simplified to match Rails: title + spinner + badges + timestamp - Task detail modal with two-pane file viewer (preview/source/diff tabs) for tasks with artifacts, compact single-column for simple tasks - Native elements for all modals with basecoat styling - Chat panel with quick actions that send messages directly - Keyboard shortcuts: Cmd+K search, N new task, Cmd+Enter execute, Cmd+Delete delete, Shift+arrows move tasks, [ ] toggle panels, ? help - UI state persistence (sidebar, chat panel, board width, focus position) - Polling reduced to 5s for near-realtime kanban updates - All button classes converted to basecoat compound format (btn-sm-ghost etc) Co-Authored-By: Claude Opus 4.6 --- pilot/package.json | 9 +- pilot/postcss.config.js | 6 - pilot/src/app.css | 317 +++++--- pilot/src/app.d.ts | 2 + pilot/src/app.html | 4 +- pilot/src/lib/api/client.ts | 87 +- pilot/src/lib/components/ApprovalsPage.svelte | 89 ++ pilot/src/lib/components/AuditLogPage.svelte | 115 +++ pilot/src/lib/components/ChatPanel.svelte | 243 ++++++ .../src/lib/components/CommandPalette.svelte | 4 +- pilot/src/lib/components/ContextMenu.svelte | 73 ++ pilot/src/lib/components/Dashboard.svelte | 266 ++++-- pilot/src/lib/components/Header.svelte | 11 +- .../lib/components/IntegrationsPage.svelte | 109 +++ pilot/src/lib/components/NewTaskDialog.svelte | 130 ++- pilot/src/lib/components/ProjectDialog.svelte | 136 ++-- pilot/src/lib/components/SandboxesPage.svelte | 155 ++++ pilot/src/lib/components/SettingsPage.svelte | 22 +- pilot/src/lib/components/Sidebar.svelte | 243 ++++++ pilot/src/lib/components/TaskBoard.svelte | 479 +++++++++-- pilot/src/lib/components/TaskCard.svelte | 145 ++-- pilot/src/lib/components/TaskDetail.svelte | 763 +++++++++++------- pilot/src/lib/server/db.ts | 394 ++++++++- pilot/src/lib/stores/chat.svelte.ts | 172 ++++ pilot/src/lib/stores/nav.svelte.ts | 71 ++ pilot/src/lib/stores/tasks.svelte.ts | 8 +- pilot/src/lib/types.ts | 143 +++- pilot/src/routes/+page.svelte | 44 +- pilot/src/routes/api/agent-actions/+server.ts | 13 + pilot/src/routes/api/chat/stream/+server.ts | 310 +++++++ pilot/src/routes/api/chats/+server.ts | 20 + pilot/src/routes/api/chats/[id]/+server.ts | 30 + .../routes/api/chats/[id]/messages/+server.ts | 15 + pilot/src/routes/api/integrations/+server.ts | 10 + pilot/src/routes/api/models/+server.ts | 10 + pilot/src/routes/api/projects/+server.ts | 4 +- pilot/src/routes/api/projects/[id]/+server.ts | 2 +- pilot/src/routes/api/sandboxes/+server.ts | 18 + pilot/src/routes/api/settings/+server.ts | 2 +- pilot/src/routes/api/tasks/+server.ts | 2 +- pilot/src/routes/api/tasks/[id]/+server.ts | 2 +- .../src/routes/api/tasks/[id]/file/+server.ts | 25 + pilot/src/routes/api/workspaces/+server.ts | 23 + .../src/routes/api/workspaces/[id]/+server.ts | 37 + pilot/static/icon.svg | 6 + pilot/tailwind.config.js | 46 -- pilot/vite.config.ts | 3 +- pilot/wrangler.jsonc | 12 +- 48 files changed, 3931 insertions(+), 899 deletions(-) delete mode 100644 pilot/postcss.config.js create mode 100644 pilot/src/lib/components/ApprovalsPage.svelte create mode 100644 pilot/src/lib/components/AuditLogPage.svelte create mode 100644 pilot/src/lib/components/ChatPanel.svelte create mode 100644 pilot/src/lib/components/ContextMenu.svelte create mode 100644 pilot/src/lib/components/IntegrationsPage.svelte create mode 100644 pilot/src/lib/components/SandboxesPage.svelte create mode 100644 pilot/src/lib/components/Sidebar.svelte create mode 100644 pilot/src/lib/stores/chat.svelte.ts create mode 100644 pilot/src/lib/stores/nav.svelte.ts create mode 100644 pilot/src/routes/api/agent-actions/+server.ts create mode 100644 pilot/src/routes/api/chat/stream/+server.ts create mode 100644 pilot/src/routes/api/chats/+server.ts create mode 100644 pilot/src/routes/api/chats/[id]/+server.ts create mode 100644 pilot/src/routes/api/chats/[id]/messages/+server.ts create mode 100644 pilot/src/routes/api/integrations/+server.ts create mode 100644 pilot/src/routes/api/models/+server.ts create mode 100644 pilot/src/routes/api/sandboxes/+server.ts create mode 100644 pilot/src/routes/api/tasks/[id]/file/+server.ts create mode 100644 pilot/src/routes/api/workspaces/+server.ts create mode 100644 pilot/src/routes/api/workspaces/[id]/+server.ts create mode 100644 pilot/static/icon.svg delete mode 100644 pilot/tailwind.config.js diff --git a/pilot/package.json b/pilot/package.json index 90b8a011..e71bb65f 100644 --- a/pilot/package.json +++ b/pilot/package.json @@ -16,11 +16,10 @@ "@sveltejs/adapter-cloudflare": "^4.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", - "autoprefixer": "^10.4.19", - "postcss": "^8.4.38", + "@tailwindcss/vite": "^4.0.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", - "tailwindcss": "^3.4.4", + "tailwindcss": "^4.0.0", "typescript": "^5.0.0", "vite": "^5.0.0", "vitest": "^2.0.0", @@ -28,6 +27,8 @@ }, "dependencies": { "@cloudflare/sandbox": "^0.4.0", - "lucide-svelte": "^0.460.0" + "basecoat-css": "^0.3.11", + "lucide-svelte": "^0.460.0", + "marked": "^17.0.2" } } diff --git a/pilot/postcss.config.js b/pilot/postcss.config.js deleted file mode 100644 index 7b75c83a..00000000 --- a/pilot/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/pilot/src/app.css b/pilot/src/app.css index bb2b709b..45660ed8 100644 --- a/pilot/src/app.css +++ b/pilot/src/app.css @@ -1,130 +1,243 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; +@import "basecoat-css"; + +@custom-variant dark (&:where(.dark, .dark *)); @layer base { :root { - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - --primary: 262 83% 58%; - --primary-foreground: 0 0% 100%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - --accent: 262 83% 95%; - --accent-foreground: 262 83% 40%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 262 83% 58%; - --radius: 0.75rem; - - --status-backlog: 240 5% 65%; - --status-backlog-bg: 240 5% 96%; - --status-queued: 45 93% 47%; - --status-queued-bg: 45 93% 95%; - --status-processing: 217 91% 60%; - --status-processing-bg: 217 91% 95%; - --status-blocked: 0 84% 60%; - --status-blocked-bg: 0 84% 95%; - --status-done: 142 71% 45%; - --status-done-bg: 142 71% 95%; - - --glow-primary: 0 0 20px hsla(262, 83%, 58%, 0.3); + --sidebar-width: 16rem; + --sidebar-collapsed-width: 4rem; } .dark { - --background: 240 10% 6%; - --foreground: 0 0% 98%; - --card: 240 10% 8%; - --card-foreground: 0 0% 98%; - --primary: 262 83% 65%; - --primary-foreground: 0 0% 100%; - --secondary: 240 4% 16%; - --secondary-foreground: 0 0% 98%; - --muted: 240 4% 16%; - --muted-foreground: 240 5% 64.9%; - --accent: 262 40% 20%; - --accent-foreground: 262 83% 75%; - --destructive: 0 62.8% 50%; - --destructive-foreground: 0 0% 98%; - --border: 240 4% 18%; - --input: 240 4% 18%; - --ring: 262 83% 65%; - - --status-backlog: 240 5% 55%; - --status-backlog-bg: 240 5% 15%; - --status-queued: 45 93% 55%; - --status-queued-bg: 45 50% 15%; - --status-processing: 217 91% 65%; - --status-processing-bg: 217 50% 15%; - --status-blocked: 0 84% 60%; - --status-blocked-bg: 0 50% 15%; - --status-done: 142 71% 50%; - --status-done-bg: 142 40% 15%; - - --glow-primary: 0 0 30px hsla(262, 83%, 65%, 0.4); + --sidebar-accent: oklch(0.32 0.01 260); + --sidebar-accent-foreground: oklch(0.985 0 0); } -} -@layer base { - * { - @apply border-border; - } body { - @apply bg-background text-foreground antialiased; + @apply antialiased; } } -@layer utilities { - .scrollbar-thin { - scrollbar-width: thin; - scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; +/* Status colors */ +:root { + --status-backlog: oklch(0.65 0.01 285); + --status-backlog-bg: oklch(0.96 0.003 285); + --status-queued: oklch(0.7 0.17 85); + --status-queued-bg: oklch(0.95 0.05 85); + --status-processing: oklch(0.6 0.19 260); + --status-processing-bg: oklch(0.95 0.04 260); + --status-blocked: oklch(0.6 0.22 27); + --status-blocked-bg: oklch(0.95 0.04 27); + --status-done: oklch(0.55 0.17 150); + --status-done-bg: oklch(0.95 0.04 150); + --status-failed: oklch(0.55 0.2 27); + --status-failed-bg: oklch(0.95 0.04 27); +} + +.dark { + --status-backlog: oklch(0.55 0.01 285); + --status-backlog-bg: oklch(0.2 0.005 285); + --status-queued: oklch(0.7 0.17 85); + --status-queued-bg: oklch(0.25 0.05 85); + --status-processing: oklch(0.65 0.19 260); + --status-processing-bg: oklch(0.22 0.04 260); + --status-blocked: oklch(0.6 0.22 27); + --status-blocked-bg: oklch(0.22 0.04 27); + --status-done: oklch(0.6 0.17 150); + --status-done-bg: oklch(0.22 0.04 150); + --status-failed: oklch(0.6 0.2 27); + --status-failed-bg: oklch(0.22 0.04 27); +} + +/* Sidebar navigation item text truncation */ +.sidebar nav > section ul li > a, +.sidebar nav > section ul li > button { + @apply flex items-center gap-2 min-w-0; +} + +.sidebar nav > section ul li > a > span:not(.flex-shrink-0):not(.sidebar-item-initial), +.sidebar nav > section ul li > button > span:not(.flex-shrink-0):not(.sidebar-item-initial) { + @apply truncate min-w-0; +} + +/* Sidebar collapsed icon-rail state */ +.sidebar[data-collapsed] { + --sidebar-width: var(--sidebar-collapsed-width); + + nav { + overflow: hidden; } - .scrollbar-thin::-webkit-scrollbar { - width: 6px; - height: 6px; + + & + * { + --sidebar-width: var(--sidebar-collapsed-width); } - .scrollbar-thin::-webkit-scrollbar-track { - background: transparent; + + [data-sidebar-label] { + @apply hidden; } - .scrollbar-thin::-webkit-scrollbar-thumb { - background: hsl(var(--muted-foreground) / 0.3); - border-radius: 3px; + + [data-sidebar-collapsed-only] { + @apply block!; + } + + nav > hr[role="separator"], + nav > section > h3, + nav > section > [role="group"] > h3, + nav > section [role="group"] h3 { + @apply hidden; + } + + /* Hide delete buttons and other non-icon elements in collapsed chat items */ + nav > section ul li > a > button, + nav > section ul li .group > button { + @apply hidden; + } + + /* Center footer buttons */ + nav > footer > button, + nav > footer > div { + @apply justify-center px-0; } - .text-gradient { - background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(262, 83%, 75%) 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; + nav > header > div { + @apply flex-col items-center gap-1.5 px-0; } - .glow-primary { - box-shadow: var(--glow-primary); + nav > header > div > button { + @apply ml-0; } - .border-status-backlog { border-left: 3px solid hsl(var(--status-backlog)); } - .border-status-queued { border-left: 3px solid hsl(var(--status-queued)); } - .border-status-processing { border-left: 3px solid hsl(var(--status-processing)); } - .border-status-blocked { border-left: 3px solid hsl(var(--status-blocked)); } - .border-status-done { border-left: 3px solid hsl(var(--status-done)); } + nav > section ul li > a, + nav > section ul li > button { + @apply justify-center px-0; + } + + nav > section ul li > a > span:not(.sidebar-item-initial), + nav > section ul li > button > span:not(.sidebar-item-initial) { + @apply hidden; + } +} + +/* Resize handle between panes */ +.resize-handle { + @apply w-1 flex-shrink-0 cursor-col-resize bg-transparent hover:bg-primary/20 transition-colors relative; } -.font-mono { - font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, Monaco, 'Courier New', monospace; +.resize-handle::after { + content: ""; + @apply absolute inset-y-0 -left-1 -right-1; } -:focus-visible { - outline: 2px solid hsl(var(--ring)); - outline-offset: 2px; +.resize-handle.active { + @apply bg-primary/30; } -::selection { - background: hsl(var(--primary) / 0.3); - color: inherit; +/* Markdown content styling */ +.chat-markdown p { + margin: 0.25em 0; +} +.chat-markdown p:first-child { + margin-top: 0; +} +.chat-markdown p:last-child { + margin-bottom: 0; +} +.chat-markdown pre { + @apply bg-accent rounded-lg overflow-x-auto; + padding: 0.75rem 1rem; + margin: 0.5em 0; + font-size: 0.8125rem; +} +.chat-markdown code { + @apply bg-accent px-1 py-0.5 rounded; + font-size: 0.8125rem; +} +.chat-markdown pre code { + background: none; + padding: 0; +} +.chat-markdown ul, .chat-markdown ol { + padding-left: 1.25rem; + margin: 0.25em 0; +} +.chat-markdown li { + margin: 0.125em 0; +} +.chat-markdown blockquote { + @apply border-l-3 border-muted-foreground/30 pl-3 italic text-muted-foreground; + margin: 0.5em 0; +} +.chat-markdown table { + border-collapse: collapse; + margin: 0.5em 0; + font-size: 0.8125rem; +} +.chat-markdown th, .chat-markdown td { + @apply border border-border; + padding: 0.25rem 0.5rem; +} + +/* Custom scrollbar utility */ +@utility scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: oklch(0.556 0.015 285 / 0.3) transparent; + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: oklch(0.556 0.015 285 / 0.3); + border-radius: 3px; + } +} + +/* Status border utilities */ +@utility border-status-backlog { + border-left: 3px solid var(--status-backlog); +} +@utility border-status-queued { + border-left: 3px solid var(--status-queued); +} +@utility border-status-processing { + border-left: 3px solid var(--status-processing); +} +@utility border-status-blocked { + border-left: 3px solid var(--status-blocked); +} +@utility border-status-done { + border-left: 3px solid var(--status-done); +} +@utility border-status-failed { + border-left: 3px solid var(--status-failed); +} + +/* Ensure dialogs are visible when open (fallback if transition-discrete not supported) */ +dialog.dialog[open] { + opacity: 1; +} +dialog.dialog[open]::backdrop { + opacity: 1; +} +dialog.dialog[open] > * { + scale: 1; +} + +/* Animations */ +@theme { + --animate-slide-in-right: slide-in-right 0.2s ease-out; + --animate-fade-in: fade-in 0.15s ease-out; + + @keyframes slide-in-right { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } } diff --git a/pilot/src/app.d.ts b/pilot/src/app.d.ts index a2a8d8d7..7cf6426b 100644 --- a/pilot/src/app.d.ts +++ b/pilot/src/app.d.ts @@ -12,10 +12,12 @@ declare global { DB: D1Database; SANDBOX: DurableObjectNamespace; SESSIONS: KVNamespace; + STORAGE?: R2Bucket; GOOGLE_CLIENT_ID?: string; GOOGLE_CLIENT_SECRET?: string; GITHUB_CLIENT_ID?: string; GITHUB_CLIENT_SECRET?: string; + ANTHROPIC_API_KEY?: string; SESSION_SECRET?: string; ENVIRONMENT?: string; }; diff --git a/pilot/src/app.html b/pilot/src/app.html index b42122b8..3a766f5e 100644 --- a/pilot/src/app.html +++ b/pilot/src/app.html @@ -1,9 +1,11 @@ + - + + TaskYou %sveltekit.head% diff --git a/pilot/src/lib/api/client.ts b/pilot/src/lib/api/client.ts index f3e72e59..954638b0 100644 --- a/pilot/src/lib/api/client.ts +++ b/pilot/src/lib/api/client.ts @@ -7,6 +7,13 @@ import type { CreateProjectRequest, UpdateProjectRequest, TaskLog, + Chat, + Message, + Model, + AgentAction, + Integration, + Sandbox, + Workspace, } from '$lib/types'; async function fetchJSON(path: string, options?: RequestInit): Promise { @@ -20,7 +27,7 @@ async function fetchJSON(path: string, options?: RequestInit): Promise { }); if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); + const error = await response.json().catch(() => ({ error: response.statusText })) as { error?: string }; throw new Error(error.error || 'Request failed'); } @@ -93,6 +100,84 @@ export const projects = { fetchJSON(`/projects/${id}`, { method: 'DELETE' }), }; +// Chats API +export const chats = { + list: () => fetchJSON('/chats'), + get: (id: string) => fetchJSON(`/chats/${id}`), + create: (data?: { title?: string; model_id?: string }) => + fetchJSON('/chats', { + method: 'POST', + body: JSON.stringify(data || {}), + }), + update: (id: string, data: { title?: string; model_id?: string }) => + fetchJSON(`/chats/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: string) => + fetchJSON(`/chats/${id}`, { method: 'DELETE' }), +}; + +// Messages API +export const messages = { + list: (chatId: string) => fetchJSON(`/chats/${chatId}/messages`), +}; + +// Models API +export const models = { + list: () => fetchJSON('/models'), +}; + +// Agent Actions API +export const agentActions = { + list: (options?: { limit?: number; offset?: number }) => { + const params = new URLSearchParams(); + if (options?.limit) params.set('limit', String(options.limit)); + if (options?.offset) params.set('offset', String(options.offset)); + const query = params.toString(); + return fetchJSON(`/agent-actions${query ? `?${query}` : ''}`); + }, +}; + +// Integrations API +export const integrations = { + list: () => fetchJSON('/integrations'), +}; + +// Sandboxes API +export const sandboxes = { + list: () => fetchJSON('/sandboxes'), + create: (data?: { name?: string }) => + fetchJSON('/sandboxes', { + method: 'POST', + body: JSON.stringify(data || {}), + }), + start: (id: string) => + fetchJSON(`/sandboxes/${id}/start`, { method: 'POST' }), + stop: (id: string) => + fetchJSON(`/sandboxes/${id}/stop`, { method: 'POST' }), + delete: (id: string) => + fetchJSON(`/sandboxes/${id}`, { method: 'DELETE' }), +}; + +// Workspaces API +export const workspaces = { + list: () => fetchJSON('/workspaces'), + get: (id: string) => fetchJSON(`/workspaces/${id}`), + create: (data: { name: string }) => + fetchJSON('/workspaces', { + method: 'POST', + body: JSON.stringify(data), + }), + update: (id: string, data: { name?: string; autonomous_enabled?: boolean; weekly_budget_cents?: number }) => + fetchJSON(`/workspaces/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: string) => + fetchJSON(`/workspaces/${id}`, { method: 'DELETE' }), +}; + // Settings API export const settings = { get: () => fetchJSON>('/settings'), diff --git a/pilot/src/lib/components/ApprovalsPage.svelte b/pilot/src/lib/components/ApprovalsPage.svelte new file mode 100644 index 00000000..1a3f4dd7 --- /dev/null +++ b/pilot/src/lib/components/ApprovalsPage.svelte @@ -0,0 +1,89 @@ + + +
+
+
+ +

Approvals

+
+ + {#if pendingTasks.length === 0 && blockedTasks.length === 0} +
+
+ +
+

All Clear

+

No tasks pending approval

+
+ {:else} + + {#if pendingTasks.length > 0} +
+

Pending Review

+
+ {#each pendingTasks as task} +
+
+
+

{task.title}

+ {#if task.body} +

{task.body}

+ {/if} +
+ {task.type} + {task.project} +
+
+
+ + +
+
+
+ {/each} +
+
+ {/if} + + + {#if blockedTasks.length > 0} +
+

Needs Attention

+
+ {#each blockedTasks as task} +
+
+ +
+

{task.title}

+

Blocked since {new Date(task.updated_at).toLocaleDateString()}

+
+ {task.type} +
+
+ {/each} +
+
+ {/if} + {/if} +
+
diff --git a/pilot/src/lib/components/AuditLogPage.svelte b/pilot/src/lib/components/AuditLogPage.svelte new file mode 100644 index 00000000..749e7abc --- /dev/null +++ b/pilot/src/lib/components/AuditLogPage.svelte @@ -0,0 +1,115 @@ + + +
+
+
+ +

Audit Log

+
+ + {#if loading} +
+
+
+ {:else if actions.length === 0} +
+
+ +
+

No Actions Yet

+

Agent actions will appear here as they execute

+
+ {:else} +
+ + + + + + + + + + + + {#each actions as action} + + + + + + + + {/each} + +
ActionRiskCostStatusTime
+
{action.action_type}
+
{action.description}
+
+ {#if action.risk_level === 'high'} + + + {action.risk_level} + + {:else if action.risk_level === 'medium'} + + + {action.risk_level} + + {:else} + + + {action.risk_level} + + {/if} + + {#if action.cost_cents > 0} + + + {(action.cost_cents / 100).toFixed(2)} + + {:else} + - + {/if} + + + {action.status.replace('_', ' ')} + + + {new Date(action.created_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} +
+
+ {/if} +
+
diff --git a/pilot/src/lib/components/ChatPanel.svelte b/pilot/src/lib/components/ChatPanel.svelte new file mode 100644 index 00000000..984820c6 --- /dev/null +++ b/pilot/src/lib/components/ChatPanel.svelte @@ -0,0 +1,243 @@ + + +
+ +
+
+ +

Pilot Chat

+
+
+ + +
+
+ + +
+ {#if chatState.messages.length === 0 && !chatState.streaming} + +
+
+ +
+

Chat with Pilot

+

+ Ask me to create tasks, check progress, or help plan your work. +

+
+ {#each quickActions as action} + + {/each} +
+
+ {:else} + {#each chatState.messages as message (message.id)} +
+ +
+ {#if message.role === 'user'} +
+ +
+ {:else} +
+ +
+ {/if} +
+ + +
+
+ {message.role === 'user' ? 'You' : 'Pilot'} + {#if message.input_tokens || message.output_tokens} + + {formatTokens(message.input_tokens)}in / {formatTokens(message.output_tokens)}out + + {/if} +
+ {#if message.role === 'assistant'} +
+ {@html renderMarkdown(message.content)} +
+ {:else} +
+ {message.content} +
+ {/if} +
+
+ {/each} + + + {#if chatState.streaming} +
+
+
+ +
+
+
+
+ Pilot + +
+ {#if chatState.streamingContent} +
{@html renderMarkdown(chatState.streamingContent)}
+ {:else} +
+ + + +
+ {/if} +
+
+ {/if} + {/if} + +
+
+ + +
+
+ + +
+
+
+ diff --git a/pilot/src/lib/components/CommandPalette.svelte b/pilot/src/lib/components/CommandPalette.svelte index 11eeb9c6..b5c534f5 100644 --- a/pilot/src/lib/components/CommandPalette.svelte +++ b/pilot/src/lib/components/CommandPalette.svelte @@ -92,7 +92,7 @@
@@ -104,7 +104,7 @@ placeholder="Search tasks or run a command..." class="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" /> - esc + esc
diff --git a/pilot/src/lib/components/ContextMenu.svelte b/pilot/src/lib/components/ContextMenu.svelte new file mode 100644 index 00000000..507facde --- /dev/null +++ b/pilot/src/lib/components/ContextMenu.svelte @@ -0,0 +1,73 @@ + + +
+ {#each items as item} + {#if item.separator} +
+ {/if} + + {/each} +
diff --git a/pilot/src/lib/components/Dashboard.svelte b/pilot/src/lib/components/Dashboard.svelte index 78a4e8ef..274d3762 100644 --- a/pilot/src/lib/components/Dashboard.svelte +++ b/pilot/src/lib/components/Dashboard.svelte @@ -1,26 +1,37 @@ -
-
(showCommandPalette = true)} - /> +
+ +
+ +
+
+ (showNewTask = true)} + /> +
+
-
- (showNewTask = true)} - /> -
+ {#if navState.chatPanelOpen} + + +
+
+
+
+ + +
+ +
+ {/if} +
- + + +{#if showKeyboardHelp} + + (showKeyboardHelp = false)} + onclick={(e) => { if (e.target === keyboardHelpEl) keyboardHelpEl.close(); }} + > +
+
+

Keyboard Shortcuts

+
+
+
+
+

Navigation

+
+
Navigate board
+
Open task
+
SearchK
+
+
+
+

Actions

+
+
New taskN
+
Execute task
+
RefreshR
+
Select / deselect taskX
+
Move task left/right
+
Delete task
+
+
+
+

Panels

+
+
Toggle sidebar[
+
Toggle chat]
+
+
+
+

General

+
+
Close / dismissEsc
+
This help?
+
+
+
+
+
+ +
+ +
+
+{/if} - (showCommandPalette = false)} - tasks={taskState.tasks} - onSelectTask={(task) => (selectedTask = task)} - onNewTask={() => (showNewTask = true)} - {onSettings} + (showCommandPalette = false)} + tasks={taskState.tasks} + onSelectTask={(task) => (selectedTask = task)} + onNewTask={() => (showNewTask = true)} + onSettings={() => {}} +/> + +{#if showNewTask} + (showNewTask = false)} /> +{/if} - {#if showNewTask} - (showNewTask = false)} - /> - {/if} - - {#if selectedTask} - (selectedTask = null)} - onUpdate={handleTaskUpdate} - onDelete={() => (selectedTask = null)} - /> - {/if} -
+{#if selectedTask} + (selectedTask = null)} + onUpdate={handleTaskUpdate} + onDelete={() => (selectedTask = null)} + /> +{/if} diff --git a/pilot/src/lib/components/Header.svelte b/pilot/src/lib/components/Header.svelte index 8db25091..53e7b18c 100644 --- a/pilot/src/lib/components/Header.svelte +++ b/pilot/src/lib/components/Header.svelte @@ -60,24 +60,23 @@
- - diff --git a/pilot/src/lib/components/IntegrationsPage.svelte b/pilot/src/lib/components/IntegrationsPage.svelte new file mode 100644 index 00000000..e5011a03 --- /dev/null +++ b/pilot/src/lib/components/IntegrationsPage.svelte @@ -0,0 +1,109 @@ + + +
+
+
+ +

Integrations

+
+ +

+ Connect external services to extend Pilot's capabilities. +

+ + {#if loading} +
+
+
+ {:else} +
+ {#each providers as provider} + {@const status = getStatus(provider.id)} +
+
+
+ +
+
+
+

{provider.name}

+ {#if status === 'connected'} + + + Connected + + {:else if status === 'error'} + + + Error + + {/if} +
+

{provider.description}

+ {#if status === 'connected'} + + {:else} + + {/if} +
+
+
+ {/each} +
+ {/if} +
+
diff --git a/pilot/src/lib/components/NewTaskDialog.svelte b/pilot/src/lib/components/NewTaskDialog.svelte index 8061d76d..7c6bd2b6 100644 --- a/pilot/src/lib/components/NewTaskDialog.svelte +++ b/pilot/src/lib/components/NewTaskDialog.svelte @@ -1,5 +1,5 @@ - - -
- -
- - -
-
- -
-
-
- -
-
-

New Task

-

What would you like AI to do?

-
-
- -
+ + + +
+

New Task

+

What would you like AI to do?

+
- -
+
+
-
-
- -
- - -
- -
-
+
+ + +
+ + + +
diff --git a/pilot/src/lib/components/ProjectDialog.svelte b/pilot/src/lib/components/ProjectDialog.svelte index 7f2103b4..ddc0282d 100644 --- a/pilot/src/lib/components/ProjectDialog.svelte +++ b/pilot/src/lib/components/ProjectDialog.svelte @@ -17,9 +17,24 @@ let instructions = $state(project?.instructions || ''); let color = $state(project?.color || '#888888'); let submitting = $state(false); + let dialogEl: HTMLDialogElement; const isEditing = !!project; + $effect(() => { + if (dialogEl && !dialogEl.open) { + dialogEl.showModal(); + } + }); + + function handleDialogClose() { + onClose(); + } + + function handleBackdropClick(e: MouseEvent) { + if (e.target === dialogEl) dialogEl.close(); + } + async function handleSubmit(e: SubmitEvent) { e.preventDefault(); if (!name.trim()) return; @@ -27,7 +42,7 @@ submitting = true; try { await onSubmit({ name: name.trim(), path: path.trim(), aliases: aliases.trim(), instructions: instructions.trim(), color }); - onClose(); + dialogEl.close(); } catch (err) { console.error(err); } finally { @@ -39,92 +54,71 @@ if (!confirm('Delete this project?')) return; try { await onDelete?.(); - onClose(); + dialogEl.close(); } catch (err) { console.error(err); } } - - -
-
+ + +
+
+

{isEditing ? 'Edit Project' : 'New Project'}

+
-
- -
-

{isEditing ? 'Edit Project' : 'New Project'}

- -
- -
-
- - +
+
+
+ +
-
- - +
+ +
-
- - +
+ +
-
- +
+
-
- - +
+ +
+
-
- {#if isEditing && onDelete} - - {:else} -
- {/if} -
- - -
-
- -
-
+
+ {#if isEditing && onDelete} + + {/if} + + +
+ + + +
diff --git a/pilot/src/lib/components/SandboxesPage.svelte b/pilot/src/lib/components/SandboxesPage.svelte new file mode 100644 index 00000000..52f00548 --- /dev/null +++ b/pilot/src/lib/components/SandboxesPage.svelte @@ -0,0 +1,155 @@ + + +
+
+
+
+ +

Sandboxes

+
+ +
+ +

+ Isolated execution environments for running tasks. Powered by Cloudflare Containers. +

+ + {#if loading} +
+
+
+ {:else if sandboxes.length === 0} +
+
+ +
+

No Sandboxes

+

Create a sandbox to start executing tasks

+ +
+ {:else} +
+ {#each sandboxes as sandbox (sandbox.id)} + {@const colors = statusColors[sandbox.status] || statusColors.stopped} +
+
+
+
+ +
+
+
+

{sandbox.name}

+ + + {sandbox.status} + +
+

+ {sandbox.provider} • Created {new Date(sandbox.created_at).toLocaleDateString()} +

+
+
+
+ {#if sandbox.status === 'stopped' || sandbox.status === 'pending'} + + {:else if sandbox.status === 'running'} + + {/if} + +
+
+ + + {#if sandbox.status === 'running'} +
+
$ sandbox ready
+
Waiting for tasks...
+
+ {/if} +
+ {/each} +
+ {/if} +
+
diff --git a/pilot/src/lib/components/SettingsPage.svelte b/pilot/src/lib/components/SettingsPage.svelte index 20470130..2175d21e 100644 --- a/pilot/src/lib/components/SettingsPage.svelte +++ b/pilot/src/lib/components/SettingsPage.svelte @@ -77,7 +77,7 @@
-
@@ -88,7 +88,7 @@
-
+
@@ -117,7 +117,7 @@
-
+
@@ -157,7 +157,7 @@
-
+
@@ -169,10 +169,7 @@

Manage your projects

- @@ -211,7 +208,7 @@
-
+
@@ -233,12 +230,7 @@ +
+ + +
+ +
+ +
+ +
+ +
+
+
+ +
+ + +
+ +

Chats

+ +
+ +
+ +
+

Sandboxes

+ +
+ +
+ + + + +
+
+ {getInitials(user.name || user.email)} +
+
+

{user.name}

+

{user.email}

+
+ +
+
+ +
diff --git a/pilot/src/lib/components/TaskBoard.svelte b/pilot/src/lib/components/TaskBoard.svelte index 413b6172..84d11a1b 100644 --- a/pilot/src/lib/components/TaskBoard.svelte +++ b/pilot/src/lib/components/TaskBoard.svelte @@ -1,8 +1,13 @@ -
- -
-
-

Tasks

-
- {#if getInProgressTasks().length > 0} - - - {getInProgressTasks().length} running - - {/if} - {#if getBlockedTasks().length > 0} - - - {getBlockedTasks().length} blocked - - {/if} -
-
- -
+ // Drag-and-drop state + let draggedTask = $state(null); + let dragOverColumn = $state(null); - -
- - {@render column('Backlog', Inbox, 'No tasks waiting', 'hsl(var(--status-backlog))', getBacklogTasks(), true)} + // Multi-select state + let selectedIds = $state(new Set()); + + let selectedCount = $derived(selectedIds.size); - - {@render column('In Progress', Zap, 'Nothing running', 'hsl(var(--status-processing))', getInProgressTasks(), false)} + function toggleSelect(taskId: number) { + const next = new Set(selectedIds); + if (next.has(taskId)) { + next.delete(taskId); + } else { + next.add(taskId); + } + selectedIds = next; + } - - {@render column('Needs Attention', AlertCircle, 'All clear!', 'hsl(var(--status-blocked))', getBlockedTasks(), false)} + function clearSelection() { + selectedIds = new Set(); + } - - {@render column('Completed', CheckCircle, 'Nothing completed yet', 'hsl(var(--status-done))', getDoneTasks(), false)} -
-
+ function isSelected(taskId: number): boolean { + return selectedIds.has(taskId); + } + + // Bulk actions + async function bulkRun() { + const ids = [...selectedIds]; + clearSelection(); + for (const id of ids) { + onQueue(id); + } + } + + async function bulkDelete() { + const count = selectedIds.size; + if (!confirm(`Delete ${count} task${count > 1 ? 's' : ''}?`)) return; + const ids = [...selectedIds]; + clearSelection(); + for (const id of ids) { + await deleteTask(id); + } + fetchTasks(); + } + + async function bulkMarkDone() { + const ids = [...selectedIds]; + clearSelection(); + for (const id of ids) { + onClose(id); + } + } + + const columns = [ + { key: 'backlog', title: 'Backlog', icon: Inbox, emptyMessage: 'No tasks waiting', color: 'hsl(var(--status-backlog))', showAdd: true, targetStatus: 'backlog' as TaskStatus }, + { key: 'running', title: 'Running', icon: Zap, emptyMessage: 'Nothing running', color: 'hsl(var(--status-processing))', showAdd: false, targetStatus: 'queued' as TaskStatus }, + { key: 'blocked', title: 'Blocked', icon: AlertCircle, emptyMessage: 'All clear!', color: 'hsl(var(--status-blocked))', showAdd: false, targetStatus: 'blocked' as TaskStatus }, + { key: 'done', title: 'Done', icon: CheckCircle, emptyMessage: 'Nothing completed', color: 'hsl(var(--status-done))', showAdd: false, targetStatus: 'done' as TaskStatus }, + { key: 'failed', title: 'Failed', icon: XCircle, emptyMessage: 'No failures', color: 'hsl(var(--status-blocked))', showAdd: false, targetStatus: 'failed' as TaskStatus }, + ]; + + function getColumnTasks(key: string): Task[] { + switch (key) { + case 'backlog': return getBacklogTasks(); + case 'running': return getInProgressTasks(); + case 'blocked': return getBlockedTasks(); + case 'done': return getDoneTasks(); + case 'failed': return getFailedTasks(); + default: return []; + } + } + + // Drag handlers + function handleDragStart(e: DragEvent, task: Task) { + draggedTask = task; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(task.id)); + } + } + + function handleDragOver(e: DragEvent, columnKey: string) { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + dragOverColumn = columnKey; + } + + function handleDragLeave() { + dragOverColumn = null; + } + + async function handleDrop(e: DragEvent, targetStatus: TaskStatus) { + e.preventDefault(); + dragOverColumn = null; + + if (!draggedTask) return; + + const currentStatus = draggedTask.status; + if (currentStatus === targetStatus) { + draggedTask = null; + return; + } + + let newStatus = targetStatus; + if (targetStatus === 'queued' && currentStatus === 'backlog') { + onQueue(draggedTask.id); + } else { + await updateTask(draggedTask.id, { status: newStatus }); + } + + draggedTask = null; + } + + function handleDragEnd() { + draggedTask = null; + dragOverColumn = null; + } + + // Context menu state + let contextMenu = $state<{ x: number; y: number; task: Task } | null>(null); + + function handleContextMenu(e: MouseEvent, task: Task) { + e.preventDefault(); + contextMenu = { x: e.clientX, y: e.clientY, task }; + } + + function getContextMenuItems(task: Task) { + const items: { label: string; icon?: typeof Play; action: () => void; variant?: 'default' | 'destructive'; separator?: boolean }[] = []; + + items.push({ label: 'View Details', icon: ExternalLink, action: () => onTaskClick(task) }); + items.push({ label: 'Edit', icon: Pencil, action: () => onTaskClick(task) }); + + if (task.status === 'backlog') { + items.push({ label: 'Execute', icon: Play, action: () => onQueue(task.id) }); + } + if (task.status === 'blocked' || task.status === 'failed') { + items.push({ label: 'Retry', icon: RotateCcw, action: () => onRetry(task.id) }); + } + if (task.status === 'processing' || task.status === 'blocked') { + items.push({ label: 'Mark Done', icon: CheckCircle, action: () => onClose(task.id) }); + } + + items.push({ label: 'Copy ID', icon: Copy, action: () => navigator.clipboard.writeText(String(task.id)), separator: true }); + + items.push({ label: 'Delete', icon: Trash2, action: () => deleteTask(task.id), variant: 'destructive', separator: true }); + + return items; + } + + async function moveTaskToColumn(task: Task, targetColIdx: number) { + const targetStatus = columns[targetColIdx].targetStatus; + if (task.status === targetStatus) return; + + if (targetStatus === 'queued' && task.status === 'backlog') { + onQueue(task.id); + } else { + await updateTask(task.id, { status: targetStatus }); + } + + setFocus(targetColIdx, navState.focusedRow); + } + + async function deleteFocusedTask(task: Task) { + if (!confirm(`Delete "${task.title}"?`)) return; + await deleteTask(task.id); + const remaining = getColumnTasks(columns[navState.focusedColumn].key); + setFocus(navState.focusedColumn, Math.min(navState.focusedRow, Math.max(0, remaining.length - 2))); + } + + // Keyboard navigation + function handleKeydown(e: KeyboardEvent) { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + if (document.querySelector('dialog[open]')) return; + + const col = navState.focusedColumn; + const row = navState.focusedRow; + const allColumnTasks = columns.map(c => getColumnTasks(c.key)); + const currentTasks = allColumnTasks[col]; + const focusedTask = currentTasks?.[row]; + + // Cmd+Enter — execute focused task + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + if (focusedTask && (focusedTask.status === 'backlog' || focusedTask.status === 'blocked' || focusedTask.status === 'failed')) { + e.preventDefault(); + onQueue(focusedTask.id); + } + return; + } + + // Cmd+Delete / Cmd+Backspace — delete focused task + if ((e.metaKey || e.ctrlKey) && (e.key === 'Delete' || e.key === 'Backspace')) { + if (focusedTask) { + e.preventDefault(); + deleteFocusedTask(focusedTask); + } + return; + } + + // Shift+Arrow — move task between columns + if (e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'H')) { + if (focusedTask && col > 0) { + e.preventDefault(); + moveTaskToColumn(focusedTask, col - 1); + } + return; + } + if (e.shiftKey && (e.key === 'ArrowRight' || e.key === 'L')) { + if (focusedTask && col < columns.length - 1) { + e.preventDefault(); + moveTaskToColumn(focusedTask, col + 1); + } + return; + } + + switch (e.key) { + case 'x': { + // Toggle select on focused task + if (focusedTask) { + e.preventDefault(); + toggleSelect(focusedTask.id); + } + break; + } + case 'Escape': { + if (selectedCount > 0) { + e.preventDefault(); + clearSelection(); + } + break; + } + case 'h': + case 'ArrowLeft': { + e.preventDefault(); + const newCol = Math.max(0, col - 1); + setFocus(newCol, Math.min(row, Math.max(0, allColumnTasks[newCol].length - 1))); + break; + } + case 'l': + case 'ArrowRight': { + e.preventDefault(); + const newCol = Math.min(columns.length - 1, col + 1); + setFocus(newCol, Math.min(row, Math.max(0, allColumnTasks[newCol].length - 1))); + break; + } + case 'j': + case 'ArrowDown': + e.preventDefault(); + setFocus(col, Math.min(row + 1, Math.max(0, allColumnTasks[col].length - 1))); + break; + case 'k': + case 'ArrowUp': + e.preventDefault(); + setFocus(col, Math.max(0, row - 1)); + break; + case 'Enter': { + e.preventDefault(); + if (focusedTask) onTaskClick(focusedTask); + break; + } + } + } + + + -{#snippet column(title: string, Icon: typeof Inbox, emptyMessage: string, accentColor: string, columnTasks: Task[], showAdd: boolean)} -
- -
0 ? accentColor : undefined} - style:border-bottom-width={columnTasks.length > 0 ? '2px' : '1px'} - > -
- -

{title}

+
+ + {#if selectedCount > 0} +
+ {selectedCount} selected +
+ + + +
- - {columnTasks.length} -
- - -
- {#each columnTasks as task (task.id)} - - {/each} - - {#if columnTasks.length === 0} -
- -

{emptyMessage}

+ {:else} + +
+
+

Tasks

+
+ {#if getInProgressTasks().length > 0} + + + {getInProgressTasks().length} + + {/if} + {#if getBlockedTasks().length > 0} + + + {getBlockedTasks().length} + + {/if}
- {/if} +
+
+ {/if} - - {#if showAdd} -
- +
+ +

{col.title}

+
+ + {columnTasks.length} + +
+ + +
+ {#each columnTasks as task, rowIdx (task.id)} + +
handleDragStart(e, task)} + ondragend={handleDragEnd} + oncontextmenu={(e) => handleContextMenu(e, task)} + class="rounded-lg" + class:ring-2={navState.focusedColumn === colIdx && navState.focusedRow === rowIdx && !isSelected(task.id)} + class:ring-primary={navState.focusedColumn === colIdx && navState.focusedRow === rowIdx && !isSelected(task.id)} + class:ring-2-selected={isSelected(task.id)} + class:opacity-50={draggedTask?.id === task.id} + > + +
+ {/each} + + {#if columnTasks.length === 0} +
+ +

{col.emptyMessage}

+
+ {/if} +
+ + + {#if col.showAdd} +
+ +
+ {/if}
- {/if} + {/each}
-{/snippet} +
+ +{#if contextMenu} + (contextMenu = null)} + /> +{/if} diff --git a/pilot/src/lib/components/TaskCard.svelte b/pilot/src/lib/components/TaskCard.svelte index 8c61175f..f042c76c 100644 --- a/pilot/src/lib/components/TaskCard.svelte +++ b/pilot/src/lib/components/TaskCard.svelte @@ -1,126 +1,71 @@
onClick(task)} > - -
-
- - {#if task.status === 'processing'} - - {:else if task.status === 'blocked'} - - {:else if task.status === 'done'} - - {:else} - - {/if} - - {config.label} -
- + +
+

{task.title}

+ {#if isRunning} + + {/if}
- -

- {task.title} -

- - - {#if task.body} -

{task.body}

- {/if} - - -
- {#if task.project && task.project !== 'personal'} - {task.project} - {/if} + +
{#if task.type} - {task.type} + {task.type} {/if} -
- - -
- {#if task.status === 'backlog'} - + {#if task.branch_name} + {task.branch_name} {/if} - {#if task.status === 'blocked'} - + {#if task.pr_url} + e.stopPropagation()} + class="badge text-[10px]" + >PR #{task.pr_number} {/if} - {#if task.status === 'processing' || task.status === 'blocked'} - +
+ + +
+ #{task.id} + {#if task.started_at && isRunning} + · Started {timeAgo(task.started_at)} + {:else if task.completed_at} + · {timeAgo(task.completed_at)} + {:else if task.created_at} + · {timeAgo(task.created_at)} {/if}
diff --git a/pilot/src/lib/components/TaskDetail.svelte b/pilot/src/lib/components/TaskDetail.svelte index 39324e55..f682cd22 100644 --- a/pilot/src/lib/components/TaskDetail.svelte +++ b/pilot/src/lib/components/TaskDetail.svelte @@ -1,11 +1,13 @@ - - -
- -
+ function fileName(path: string): string { + return path.split('/').pop() || path; + } + + function renderDiffHtml(content: string): string { + return content.split('\n').map(line => { + if (line.startsWith('+') && !line.startsWith('+++')) return `
${escapeHtml(line)}
`; + if (line.startsWith('-') && !line.startsWith('---')) return `
${escapeHtml(line)}
`; + if (line.startsWith('@@')) return `
${escapeHtml(line)}
`; + return `
${escapeHtml(line)}
`; + }).join(''); + } + + function renderSourceHtml(content: string): string { + const lines = content.split('\n'); + return lines.map((line, i) => + `${i + 1}${escapeHtml(line)}` + ).join(''); + } - -
+ function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); + } + + + + +
-
-
- {#if isEditing} - +
+

{task.title}

+
+ {config.label} + {#if task.type} + {task.type} + {/if} + {#if task.branch_name} + {task.branch_name} {:else} -

{task.title}

+ #{task.id} + {/if} + {#if isRunning} + + {/if} + {#if task.status === 'backlog' || task.status === 'blocked' || task.status === 'failed'} + + {/if} + {#if task.status === 'blocked' || task.status === 'failed'} + + {/if} + {#if isRunning} + {/if} - -
- - {#if task.status === 'processing'} - - {:else if task.status === 'blocked'} - - {:else if task.status === 'done'} - - {:else} - - {/if} - {config.label} - - {#if task.project && task.project !== 'personal'} - {task.project} - {/if} - {#if task.type} - {task.type} - {/if} - {#if task.dangerous_mode} - - - Dangerous - - {/if} -
-
- -
{#if !isEditing} - {/if} - -
-
- - -
- -
- {#if isEditing} - -
- - -
- {:else if task.body} -
{task.body}
- {:else} -

No description

+ · {timeAgo(task.created_at)} + {#if task.started_at} + · started {timeAgo(task.started_at)} + {/if} + {#if task.completed_at} + · completed {timeAgo(task.completed_at)} {/if}
+ + + +
+ {#if hasFiles} + +
+ +
+ {#if isEditing} +
+ + +
+ + +
+
+ {:else} + {#if task.body} +
+
{@html renderMarkdown(task.body)}
+
+ {/if} + + {/if} + + {#if subtasks.length > 0} +
+

Subtasks

+
+ {#each subtasks as subtask} +
+ {#if subtask.done} + + {:else} +
+ {/if} + {subtask.title} +
+ {/each} +
+
+ {/if} + + {#if task.output} +
+

Summary

+
+ {@html renderMarkdown(task.output)} +
+
+ {/if} - -
-
-
- - Created {formatDate(task.created_at)} + {#if isRunning} +
+

Live Output

+
+ {#if logs.length === 0} +

Waiting for output...

+ {:else} + {#each logs.slice(-20) as log} +
{log.content}
+ {/each} + {/if} +
+
+ {/if} + + {#if task.pr_url} + + + View PR #{task.pr_number} + + {/if}
- {#if task.started_at} -
- - - {task.completed_at - ? `Took ${formatDuration(task.started_at, task.completed_at)}` - : `Running for ${formatDuration(task.started_at)}` - } - + + +
+ +
+
+ {#each fileEntries as entry} + + {/each} +
+
+ {#each ['preview', 'source', 'diff'] as mode} + + {/each} +
- {/if} - {#if task.branch_name} -
- - {task.branch_name} + + +
+ {#if fileLoading} +
+ +
+ {:else if fileViewMode === 'diff'} +
{@html renderDiffHtml(fileContent)}
+ {:else if fileViewMode === 'source'} + + {@html renderSourceHtml(fileContent)} +
+ {:else} + {@const ext = selectedFile ? fileExtension(selectedFile) : ''} + {#if ext === '.md' || ext === '.markdown'} +
+ {@html renderMarkdown(fileContent)} +
+ {:else if ext === '.html' || ext === '.htm'} + + {:else if ext === '.json'} +
{(() => { try { return JSON.stringify(JSON.parse(fileContent), null, 2); } catch { return fileContent; } })()}
+ {:else} + + {@html renderSourceHtml(fileContent)} +
+ {/if} + {/if}
- {/if} - {#if task.pr_url} - +
+ {:else} + +
+ {#if isEditing} +
+ + +
+ + +
+ {:else} + {#if task.body} +
+
{@html renderMarkdown(task.body)}
+
+ {/if} + {/if} -
-
- -
-
- {#if task.status === 'backlog'} - + {#if subtasks.length > 0} +
+

Subtasks

+
+ {#each subtasks as subtask} +
+ {#if subtask.done} + + {:else} +
+ {/if} + {subtask.title} +
+ {/each} +
+
{/if} - {#if task.status === 'blocked'} - + {#if task.output} +
+

Summary

+
+ {@html renderMarkdown(task.output)} +
+
{/if} - {#if task.status === 'done'} - + {#if task.summary} +
+

Files Changed

+
+
{task.summary}
+
+
{/if} - {#if task.status === 'processing' || task.status === 'blocked' || task.status === 'queued'} - + {#if isRunning} +
+

Live Output

+
+ {#if logs.length === 0} +

Waiting for output...

+ {:else} + {#each logs as log} +
{log.content}
+ {/each} + {/if} +
+
+
{/if} - + {#if task.pr_url} + + + PR #{task.pr_number} + + {/if}
-
+ {/if} +
- -
- +
+
+ + +{#if showRetryDialog} + + { showRetryDialog = false; retryFeedback = ''; }} + onclick={handleRetryBackdropClick} + > +
+
+

Add Feedback

+

Provide additional context or instructions for the AI to consider when retrying this task.

+
+
+ +
+
+ + - - {#if logsExpanded} -
- {#if loading} -
Loading logs...
- {:else if logs.length === 0} -
No logs yet. Run the task to see output.
- {:else} - {#each logs as log} -
- {log.content} -
- {/each} - {/if} -
-
- {/if} -
+ +
- - - {#if showRetryDialog} -
-
-
- -

Add Feedback

-
-

- Provide additional context or instructions for the AI to consider when retrying this task. -

- -
- - -
-
-
- {/if} -
-
+ +{/if} diff --git a/pilot/src/lib/server/db.ts b/pilot/src/lib/server/db.ts index af82c92d..1be83bfc 100644 --- a/pilot/src/lib/server/db.ts +++ b/pilot/src/lib/server/db.ts @@ -1,7 +1,4 @@ -import type { Task, Project, TaskLog, TaskStatus } from '$lib/types'; - -// D1 database operations for the host (user accounts, sessions) -// Each user's task data lives in their sandbox's SQLite +import type { Task, Chat, Message, Workspace, AgentAction, Sandbox, TaskStatus, TaskLog, Project, Model, Integration } from '$lib/types'; export async function initHostDB(db: D1Database): Promise { await db.batch([ @@ -13,25 +10,57 @@ export async function initHostDB(db: D1Database): Promise { avatar_url TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL, provider_id TEXT NOT NULL, - sandbox_id TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `), + db.prepare(` + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_id TEXT NOT NULL, + autonomous_enabled INTEGER NOT NULL DEFAULT 0, + weekly_budget_cents INTEGER NOT NULL DEFAULT 10000, + budget_spent_cents INTEGER NOT NULL DEFAULT 0, + polling_interval INTEGER NOT NULL DEFAULT 30, + brand_voice TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (owner_id) REFERENCES users(id) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS memberships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, workspace_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) + ) + `), db.prepare(` CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL DEFAULT 'default', user_id TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'backlog', type TEXT NOT NULL DEFAULT 'code', project TEXT NOT NULL DEFAULT 'personal', + parent_task_id INTEGER, + subtasks_json TEXT, + cost_cents INTEGER NOT NULL DEFAULT 0, + output TEXT, worktree_path TEXT, branch_name TEXT, port INTEGER, pr_url TEXT, pr_number INTEGER, + approval_status TEXT, dangerous_mode INTEGER NOT NULL DEFAULT 0, scheduled_at TEXT, recurrence TEXT, @@ -40,7 +69,8 @@ export async function initHostDB(db: D1Database): Promise { updated_at TEXT NOT NULL DEFAULT (datetime('now')), started_at TEXT, completed_at TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (parent_task_id) REFERENCES tasks(id) ) `), db.prepare(` @@ -66,6 +96,98 @@ export async function initHostDB(db: D1Database): Promise { FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ) `), + db.prepare(` + CREATE TABLE IF NOT EXISTS chats ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT 'default', + user_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT 'New Chat', + model_id TEXT NOT NULL DEFAULT 'claude-sonnet-4-5-20250929', + sandbox_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + model_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS tool_calls ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + arguments_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS agent_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL DEFAULT 'default', + task_id INTEGER, + sandbox_id TEXT, + action_type TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + reasoning TEXT, + risk_level TEXT NOT NULL DEFAULT 'low', + cost_cents INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'completed', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (task_id) REFERENCES tasks(id) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS integrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL DEFAULT 'default', + provider TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'disconnected', + external_id TEXT, + access_token_encrypted TEXT, + refresh_token_encrypted TEXT, + token_expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS sandboxes ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL DEFAULT 'Default', + status TEXT NOT NULL DEFAULT 'pending', + provider TEXT NOT NULL DEFAULT 'cloudflare', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS models ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + provider TEXT NOT NULL, + api_id TEXT NOT NULL, + context_window INTEGER NOT NULL DEFAULT 200000, + input_price_per_million REAL NOT NULL DEFAULT 0, + output_price_per_million REAL NOT NULL DEFAULT 0, + supports_tools INTEGER NOT NULL DEFAULT 1, + supports_streaming INTEGER NOT NULL DEFAULT 1 + ) + `), db.prepare(` CREATE TABLE IF NOT EXISTS settings ( user_id TEXT NOT NULL, @@ -76,9 +198,20 @@ export async function initHostDB(db: D1Database): Promise { ) `), ]); + + // Seed default models + await db.prepare(`INSERT OR IGNORE INTO models (id, name, provider, api_id, context_window, input_price_per_million, output_price_per_million) VALUES + ('claude-sonnet-4-5-20250929', 'Claude Sonnet 4.5', 'anthropic', 'claude-sonnet-4-5-20250929', 200000, 3, 15), + ('claude-haiku-4-5-20251001', 'Claude Haiku 4.5', 'anthropic', 'claude-haiku-4-5-20251001', 200000, 0.8, 4), + ('claude-opus-4-6', 'Claude Opus 4.6', 'anthropic', 'claude-opus-4-6', 200000, 15, 75) + `).run(); + + // Seed default workspace for dev + await db.prepare(`INSERT OR IGNORE INTO workspaces (id, name, owner_id) VALUES ('default', 'Personal', 'dev-user')`).run(); } -// User operations +// ── User operations ── + export async function findOrCreateUser( db: D1Database, provider: string, @@ -88,7 +221,6 @@ export async function findOrCreateUser( avatarUrl: string, ): Promise<{ id: string; email: string; name: string; avatar_url: string }> { const id = `${provider}:${providerId}`; - const existing = await db .prepare('SELECT id, email, name, avatar_url FROM users WHERE id = ?') .bind(id) @@ -96,7 +228,7 @@ export async function findOrCreateUser( if (existing) { await db - .prepare('UPDATE users SET name = ?, avatar_url = ?, updated_at = datetime(\'now\') WHERE id = ?') + .prepare("UPDATE users SET name = ?, avatar_url = ?, updated_at = datetime('now') WHERE id = ?") .bind(name, avatarUrl, id) .run(); return { ...existing, name, avatar_url: avatarUrl }; @@ -120,7 +252,8 @@ export async function getUserById( .first(); } -// Task operations +// ── Task operations ── + export async function listTasks( db: D1Database, userId: string, @@ -133,8 +266,7 @@ export async function listTasks( query += ' AND status = ?'; params.push(options.status); } else if (!options.includeClosed) { - query += ' AND status != ?'; - params.push('done'); + query += " AND status NOT IN ('done', 'failed')"; } if (options.project) { @@ -195,7 +327,6 @@ export async function updateTask( sets.push("updated_at = datetime('now')"); - // Track status transitions if (data.status === 'processing' || data.status === 'queued') { sets.push("started_at = COALESCE(started_at, datetime('now'))"); } @@ -221,7 +352,236 @@ export async function deleteTask(db: D1Database, userId: string, taskId: number) return (result.meta?.changes ?? 0) > 0; } -// Project operations +// ── Chat operations ── + +export async function listChats(db: D1Database, userId: string): Promise { + const result = await db + .prepare('SELECT * FROM chats WHERE user_id = ? ORDER BY updated_at DESC') + .bind(userId) + .all(); + return result.results || []; +} + +export async function getChat(db: D1Database, userId: string, chatId: string): Promise { + return db + .prepare('SELECT * FROM chats WHERE id = ? AND user_id = ?') + .bind(chatId, userId) + .first(); +} + +export async function createChat( + db: D1Database, + userId: string, + data: { title?: string; model_id?: string }, +): Promise { + const id = crypto.randomUUID(); + const result = await db + .prepare('INSERT INTO chats (id, user_id, title, model_id) VALUES (?, ?, ?, ?) RETURNING *') + .bind(id, userId, data.title || 'New Chat', data.model_id || 'claude-sonnet-4-5-20250929') + .first(); + return result!; +} + +export async function updateChat( + db: D1Database, + userId: string, + chatId: string, + data: { title?: string; model_id?: string }, +): Promise { + const sets: string[] = []; + const params: (string)[] = []; + + if (data.title !== undefined) { sets.push('title = ?'); params.push(data.title); } + if (data.model_id !== undefined) { sets.push('model_id = ?'); params.push(data.model_id); } + + if (sets.length === 0) return getChat(db, userId, chatId); + + sets.push("updated_at = datetime('now')"); + params.push(chatId, userId); + + const row = await db + .prepare(`UPDATE chats SET ${sets.join(', ')} WHERE id = ? AND user_id = ? RETURNING *`) + .bind(...params) + .first(); + + return row; +} + +export async function deleteChat(db: D1Database, userId: string, chatId: string): Promise { + const result = await db + .prepare('DELETE FROM chats WHERE id = ? AND user_id = ?') + .bind(chatId, userId) + .run(); + return (result.meta?.changes ?? 0) > 0; +} + +// ── Message operations ── + +export async function listMessages(db: D1Database, chatId: string): Promise { + const result = await db + .prepare('SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC') + .bind(chatId) + .all(); + return result.results || []; +} + +export async function createMessage( + db: D1Database, + data: { chat_id: string; role: string; content: string; model_id?: string; input_tokens?: number; output_tokens?: number }, +): Promise { + const id = crypto.randomUUID(); + const result = await db + .prepare('INSERT INTO messages (id, chat_id, role, content, model_id, input_tokens, output_tokens) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *') + .bind(id, data.chat_id, data.role, data.content, data.model_id || null, data.input_tokens || 0, data.output_tokens || 0) + .first(); + return result!; +} + +// ── Model operations ── + +export async function listModels(db: D1Database): Promise { + const result = await db.prepare('SELECT * FROM models ORDER BY name').all(); + return (result.results || []).map(m => ({ + ...m, + supports_tools: Boolean(m.supports_tools), + supports_streaming: Boolean(m.supports_streaming), + })); +} + +// ── Agent Action operations ── + +export async function listAgentActions( + db: D1Database, + options: { limit?: number; offset?: number } = {}, +): Promise { + const limit = options.limit || 50; + const offset = options.offset || 0; + const result = await db + .prepare('SELECT * FROM agent_actions ORDER BY created_at DESC LIMIT ? OFFSET ?') + .bind(limit, offset) + .all(); + return result.results || []; +} + +export async function createAgentAction( + db: D1Database, + data: { action_type: string; description: string; reasoning?: string; risk_level?: string; cost_cents?: number; task_id?: number }, +): Promise { + const result = await db + .prepare('INSERT INTO agent_actions (action_type, description, reasoning, risk_level, cost_cents, task_id) VALUES (?, ?, ?, ?, ?, ?) RETURNING *') + .bind(data.action_type, data.description, data.reasoning || null, data.risk_level || 'low', data.cost_cents || 0, data.task_id || null) + .first(); + return result!; +} + +// ── Workspace operations ── + +export async function listWorkspaces(db: D1Database, userId: string): Promise { + const result = await db + .prepare( + `SELECT w.* FROM workspaces w + LEFT JOIN memberships m ON m.workspace_id = w.id AND m.user_id = ? + WHERE w.owner_id = ? OR m.user_id = ? + ORDER BY w.name`, + ) + .bind(userId, userId, userId) + .all(); + return result.results || []; +} + +export async function getWorkspace(db: D1Database, id: string): Promise { + return db.prepare('SELECT * FROM workspaces WHERE id = ?').bind(id).first(); +} + +export async function createWorkspace( + db: D1Database, + ownerId: string, + data: { name: string }, +): Promise { + const id = crypto.randomUUID(); + const result = await db + .prepare('INSERT INTO workspaces (id, name, owner_id) VALUES (?, ?, ?) RETURNING *') + .bind(id, data.name, ownerId) + .first(); + // Also add owner as member + await db + .prepare('INSERT INTO memberships (user_id, workspace_id, role) VALUES (?, ?, ?)') + .bind(ownerId, id, 'owner') + .run(); + return result!; +} + +export async function updateWorkspace( + db: D1Database, + id: string, + data: { name?: string; autonomous_enabled?: boolean; weekly_budget_cents?: number }, +): Promise { + const sets: string[] = []; + const params: (string | number)[] = []; + + if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); } + if (data.autonomous_enabled !== undefined) { sets.push('autonomous_enabled = ?'); params.push(data.autonomous_enabled ? 1 : 0); } + if (data.weekly_budget_cents !== undefined) { sets.push('weekly_budget_cents = ?'); params.push(data.weekly_budget_cents); } + + if (sets.length === 0) return null; + + sets.push("updated_at = datetime('now')"); + params.push(id); + + return db + .prepare(`UPDATE workspaces SET ${sets.join(', ')} WHERE id = ? RETURNING *`) + .bind(...params) + .first(); +} + +export async function deleteWorkspace(db: D1Database, id: string): Promise { + const result = await db.prepare('DELETE FROM workspaces WHERE id = ?').bind(id).run(); + return (result.meta?.changes ?? 0) > 0; +} + +// ── Integration operations ── + +export async function listIntegrations(db: D1Database): Promise { + const result = await db + .prepare('SELECT id, workspace_id, provider, status, external_id, created_at, updated_at FROM integrations ORDER BY provider') + .all(); + return result.results || []; +} + +// ── Sandbox operations ── + +export async function listSandboxes(db: D1Database): Promise { + const result = await db + .prepare('SELECT * FROM sandboxes ORDER BY created_at DESC') + .all(); + return result.results || []; +} + +export async function createSandbox( + db: D1Database, + data: { name?: string; provider?: string }, +): Promise { + const id = crypto.randomUUID(); + const result = await db + .prepare('INSERT INTO sandboxes (id, name, provider) VALUES (?, ?, ?) RETURNING *') + .bind(id, data.name || 'Default', data.provider || 'cloudflare') + .first(); + return result!; +} + +export async function updateSandboxStatus( + db: D1Database, + sandboxId: string, + status: string, +): Promise { + return db + .prepare("UPDATE sandboxes SET status = ?, updated_at = datetime('now') WHERE id = ? RETURNING *") + .bind(status, sandboxId) + .first(); +} + +// ── Project operations ── + export async function listProjects(db: D1Database, userId: string): Promise { const result = await db .prepare('SELECT * FROM projects WHERE user_id = ? ORDER BY name') @@ -283,7 +643,8 @@ export async function deleteProject(db: D1Database, userId: string, projectId: n return (result.meta?.changes ?? 0) > 0; } -// Task logs +// ── Task logs ── + export async function getTaskLogs( db: D1Database, userId: string, @@ -317,7 +678,8 @@ export async function addTaskLog( return result!; } -// Settings +// ── Settings ── + export async function getSettings(db: D1Database, userId: string): Promise> { const result = await db .prepare('SELECT key, value FROM settings WHERE user_id = ?') diff --git a/pilot/src/lib/stores/chat.svelte.ts b/pilot/src/lib/stores/chat.svelte.ts new file mode 100644 index 00000000..8d145ae2 --- /dev/null +++ b/pilot/src/lib/stores/chat.svelte.ts @@ -0,0 +1,172 @@ +import type { Chat, Message } from '$lib/types'; +import { chats as chatsApi, messages as messagesApi } from '$lib/api/client'; + +export const chatState = $state({ + chats: [] as Chat[], + activeChat: null as Chat | null, + messages: [] as Message[], + loading: false, + streaming: false, + streamingContent: '', +}); + +export async function fetchChats() { + try { + chatState.chats = await chatsApi.list(); + } catch (e) { + console.error('Failed to fetch chats:', e); + } +} + +export async function selectChat(chat: Chat) { + chatState.activeChat = chat; + chatState.loading = true; + try { + chatState.messages = await messagesApi.list(chat.id); + } catch (e) { + console.error('Failed to fetch messages:', e); + } finally { + chatState.loading = false; + } +} + +export async function createNewChat(modelId?: string): Promise { + const chat = await chatsApi.create({ model_id: modelId }); + chatState.chats = [chat, ...chatState.chats]; + chatState.activeChat = chat; + chatState.messages = []; + return chat; +} + +export async function deleteCurrentChat() { + if (!chatState.activeChat) return; + await chatsApi.delete(chatState.activeChat.id); + chatState.chats = chatState.chats.filter(c => c.id !== chatState.activeChat!.id); + chatState.activeChat = null; + chatState.messages = []; +} + +export async function sendMessage(content: string) { + if (!chatState.activeChat || !content.trim()) return; + + const chatId = chatState.activeChat.id; + + // Add user message optimistically + const userMsg: Message = { + id: crypto.randomUUID(), + chat_id: chatId, + role: 'user', + content: content.trim(), + input_tokens: 0, + output_tokens: 0, + created_at: new Date().toISOString(), + }; + chatState.messages = [...chatState.messages, userMsg]; + + // Auto-title from first message + if (chatState.messages.filter(m => m.role === 'user').length === 1) { + const title = content.trim().slice(0, 60) + (content.length > 60 ? '...' : ''); + chatState.activeChat = { ...chatState.activeChat, title }; + chatState.chats = chatState.chats.map(c => + c.id === chatId ? { ...c, title } : c + ); + } + + // Stream LLM response + chatState.streaming = true; + chatState.streamingContent = ''; + + try { + const response = await fetch('/api/chat/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: chatId, content: content.trim() }), + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(`Chat failed: ${response.statusText}`); + } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let fullContent = ''; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') continue; + + try { + const event = JSON.parse(data); + if (event.type === 'content_block_delta' && event.delta?.text) { + fullContent += event.delta.text; + chatState.streamingContent = fullContent; + } else if (event.type === 'message_complete') { + // Final message with token counts + if (event.message) { + const assistantMsg: Message = { + id: event.message.id || crypto.randomUUID(), + chat_id: chatId, + role: 'assistant', + content: fullContent, + input_tokens: event.message.input_tokens || 0, + output_tokens: event.message.output_tokens || 0, + model_id: event.message.model_id, + created_at: new Date().toISOString(), + }; + chatState.messages = [...chatState.messages, assistantMsg]; + } + } else if (event.type === 'tool_use') { + // Show tool call in stream + chatState.streamingContent = fullContent + `\n\n*Using tool: ${event.name}...*`; + } else if (event.type === 'error') { + fullContent += `\n\n**Error:** ${event.error}`; + chatState.streamingContent = fullContent; + } + } catch { + // Skip malformed JSON + } + } + } + } + } + + // If no message_complete event, add the message manually + if (fullContent && !chatState.messages.some(m => m.content === fullContent && m.role === 'assistant')) { + const assistantMsg: Message = { + id: crypto.randomUUID(), + chat_id: chatId, + role: 'assistant', + content: fullContent, + input_tokens: 0, + output_tokens: 0, + created_at: new Date().toISOString(), + }; + chatState.messages = [...chatState.messages, assistantMsg]; + } + } catch (e) { + console.error('Streaming failed:', e); + const errorMsg: Message = { + id: crypto.randomUUID(), + chat_id: chatId, + role: 'assistant', + content: `**Error:** ${e instanceof Error ? e.message : 'Failed to get response'}. Check that ANTHROPIC_API_KEY is configured.`, + input_tokens: 0, + output_tokens: 0, + created_at: new Date().toISOString(), + }; + chatState.messages = [...chatState.messages, errorMsg]; + } finally { + chatState.streaming = false; + chatState.streamingContent = ''; + } +} diff --git a/pilot/src/lib/stores/nav.svelte.ts b/pilot/src/lib/stores/nav.svelte.ts new file mode 100644 index 00000000..b5e959a8 --- /dev/null +++ b/pilot/src/lib/stores/nav.svelte.ts @@ -0,0 +1,71 @@ +import type { NavView } from '$lib/types'; + +function loadBool(key: string, fallback: boolean): boolean { + if (typeof localStorage === 'undefined') return fallback; + const v = localStorage.getItem(key); + if (v === null) return fallback; + return v === 'true'; +} + +function loadNumber(key: string, fallback: number): number { + if (typeof localStorage === 'undefined') return fallback; + const v = localStorage.getItem(key); + if (v === null) return fallback; + const n = parseFloat(v); + return isNaN(n) ? fallback : n; +} + +function loadString(key: string, fallback: string): string { + if (typeof localStorage === 'undefined') return fallback; + return localStorage.getItem(key) ?? fallback; +} + +export const navState = $state({ + view: 'dashboard' as NavView, + sidebarCollapsed: loadBool('ui:sidebar-collapsed', false), + sidebarMobileOpen: false, + chatPanelOpen: loadBool('ui:chat-panel-open', true), + boardWidth: loadNumber('ui:board-width', 60), + focusedColumn: loadNumber('ui:focused-column', 0), + focusedRow: loadNumber('ui:focused-row', 0), +}); + +// Persist helpers — write on change +function persist(key: string, value: string) { + if (typeof localStorage !== 'undefined') localStorage.setItem(key, value); +} + +export function navigate(view: NavView) { + navState.view = view; + navState.sidebarMobileOpen = false; +} + +export function toggleSidebar() { + navState.sidebarCollapsed = !navState.sidebarCollapsed; + persist('ui:sidebar-collapsed', String(navState.sidebarCollapsed)); +} + +export function toggleMobileSidebar() { + navState.sidebarMobileOpen = !navState.sidebarMobileOpen; +} + +export function closeMobileSidebar() { + navState.sidebarMobileOpen = false; +} + +export function toggleChatPanel() { + navState.chatPanelOpen = !navState.chatPanelOpen; + persist('ui:chat-panel-open', String(navState.chatPanelOpen)); +} + +export function setBoardWidth(width: number) { + navState.boardWidth = width; + persist('ui:board-width', String(width)); +} + +export function setFocus(column: number, row: number) { + navState.focusedColumn = column; + navState.focusedRow = row; + persist('ui:focused-column', String(column)); + persist('ui:focused-row', String(row)); +} diff --git a/pilot/src/lib/stores/tasks.svelte.ts b/pilot/src/lib/stores/tasks.svelte.ts index 6b4aa9a8..736068c9 100644 --- a/pilot/src/lib/stores/tasks.svelte.ts +++ b/pilot/src/lib/stores/tasks.svelte.ts @@ -33,6 +33,10 @@ export function getDoneTasks(): Task[] { return taskState.tasks.filter((t) => t.status === 'done').sort(byUpdatedDesc); } +export function getFailedTasks(): Task[] { + return taskState.tasks.filter((t) => t.status === 'failed').sort(byUpdatedDesc); +} + export async function fetchTasks() { taskState.loading = true; try { @@ -83,9 +87,9 @@ export async function closeTask(id: number): Promise { // Periodic refresh let pollInterval: ReturnType | null = null; -export function startPolling() { +export function startPolling(interval = 5000) { stopPolling(); - pollInterval = setInterval(fetchTasks, 5000); + pollInterval = setInterval(fetchTasks, interval); } export function stopPolling() { diff --git a/pilot/src/lib/types.ts b/pilot/src/lib/types.ts index d4eca91f..5ecdceea 100644 --- a/pilot/src/lib/types.ts +++ b/pilot/src/lib/types.ts @@ -4,14 +4,32 @@ export interface User { email: string; name: string; avatar_url: string; - sandbox_id?: string; - sandbox_status?: SandboxStatus; } -export type SandboxStatus = 'pending' | 'creating' | 'running' | 'stopped' | 'error'; +// Workspace types +export interface Workspace { + id: string; + name: string; + owner_id: string; + autonomous_enabled: boolean; + weekly_budget_cents: number; + budget_spent_cents: number; + polling_interval: number; + brand_voice: string; + created_at: string; + updated_at: string; +} + +export interface Membership { + id: number; + user_id: string; + workspace_id: string; + role: 'owner' | 'admin' | 'member'; + created_at: string; +} // Task types -export type TaskStatus = 'backlog' | 'queued' | 'processing' | 'blocked' | 'done'; +export type TaskStatus = 'backlog' | 'queued' | 'processing' | 'blocked' | 'done' | 'failed'; export interface Task { id: number; @@ -20,11 +38,18 @@ export interface Task { status: TaskStatus; type: string; project: string; + workspace_id: string; + parent_task_id?: number; + subtasks_json?: string; + cost_cents: number; worktree_path?: string; branch_name?: string; port?: number; pr_url?: string; pr_number?: number; + output?: string; + summary?: string; + approval_status?: 'pending_review' | 'approved' | 'rejected'; dangerous_mode: boolean; scheduled_at?: string; recurrence?: string; @@ -77,6 +102,96 @@ export interface UpdateProjectRequest { color?: string; } +// Chat types +export interface Chat { + id: string; + workspace_id: string; + user_id: string; + title: string; + model_id: string; + sandbox_id?: string; + created_at: string; + updated_at: string; +} + +export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; + +export interface Message { + id: string; + chat_id: string; + role: MessageRole; + content: string; + input_tokens: number; + output_tokens: number; + model_id?: string; + created_at: string; +} + +export interface ToolCall { + id: string; + message_id: string; + tool_name: string; + arguments_json: string; + result_json?: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + created_at: string; +} + +// Agent action types +export type RiskLevel = 'low' | 'medium' | 'high'; +export type ActionStatus = 'completed' | 'pending_approval' | 'rejected' | 'failed'; + +export interface AgentAction { + id: number; + workspace_id: string; + task_id?: number; + sandbox_id?: string; + action_type: string; + description: string; + reasoning?: string; + risk_level: RiskLevel; + cost_cents: number; + status: ActionStatus; + created_at: string; +} + +// Integration types +export interface Integration { + id: number; + workspace_id: string; + provider: 'github' | 'gmail' | 'slack' | 'linear'; + status: 'connected' | 'disconnected' | 'error'; + external_id?: string; + created_at: string; + updated_at: string; +} + +// Sandbox types +export type SandboxStatus = 'pending' | 'provisioning' | 'running' | 'stopped' | 'error'; + +export interface Sandbox { + id: string; + workspace_id: string; + name: string; + status: SandboxStatus; + provider: 'cloudflare' | 'local'; + created_at: string; + updated_at: string; +} + +// Model types +export interface Model { + id: string; + name: string; + provider: string; + api_id: string; + context_window: number; + input_price_per_million: number; + output_price_per_million: number; + supports_tools: boolean; + supports_streaming: boolean; +} + // Task log types export interface TaskLog { id: number; @@ -86,8 +201,18 @@ export interface TaskLog { created_at: string; } -// WebSocket message types -export type WebSocketMessage = - | { type: 'task_update'; data: Task } - | { type: 'task_deleted'; data: { id: number } } - | { type: 'task_log'; data: { task_id: number; log: TaskLog } }; +// Inbound message types +export interface InboundMessage { + id: number; + workspace_id: string; + channel: 'email' | 'telegram' | 'webhook'; + sender: string; + subject?: string; + body: string; + processed: boolean; + chat_id?: string; + created_at: string; +} + +// Navigation +export type NavView = 'dashboard' | 'workspaces' | 'sandboxes' | 'integrations' | 'approvals' | 'settings'; diff --git a/pilot/src/routes/+page.svelte b/pilot/src/routes/+page.svelte index c65310e7..a48eef43 100644 --- a/pilot/src/routes/+page.svelte +++ b/pilot/src/routes/+page.svelte @@ -2,11 +2,17 @@ import { untrack } from 'svelte'; import { authState, fetchUser, logout } from '$lib/stores/auth.svelte'; import { fetchTasks, startPolling, stopPolling } from '$lib/stores/tasks.svelte'; + import { fetchChats } from '$lib/stores/chat.svelte'; + import { navState, toggleMobileSidebar } from '$lib/stores/nav.svelte'; import LoginPage from '$lib/components/LoginPage.svelte'; + import Sidebar from '$lib/components/Sidebar.svelte'; import Dashboard from '$lib/components/Dashboard.svelte'; import SettingsPage from '$lib/components/SettingsPage.svelte'; + import ApprovalsPage from '$lib/components/ApprovalsPage.svelte'; + import IntegrationsPage from '$lib/components/IntegrationsPage.svelte'; + import SandboxesPage from '$lib/components/SandboxesPage.svelte'; + import { Menu } from 'lucide-svelte'; - let view = $state<'dashboard' | 'settings'>('dashboard'); let initialized = $state(false); $effect(() => { @@ -17,6 +23,7 @@ fetchUser().then(() => { if (authState.user) { fetchTasks(); + fetchChats(); startPolling(); } }); @@ -42,12 +49,33 @@
{:else if !authState.user} -{:else if view === 'settings'} - (view = 'dashboard')} /> {:else} - (view = 'settings')} - /> + + + + +
+ +
+ + TaskYou +
+ + +
+ {#if navState.view === 'dashboard'} + + {:else if navState.view === 'settings'} + (navState.view = 'dashboard')} /> + {:else if navState.view === 'approvals'} + + {:else if navState.view === 'integrations'} + + {:else if navState.view === 'sandboxes'} + + {/if} +
+
{/if} diff --git a/pilot/src/routes/api/agent-actions/+server.ts b/pilot/src/routes/api/agent-actions/+server.ts new file mode 100644 index 00000000..a4d504ed --- /dev/null +++ b/pilot/src/routes/api/agent-actions/+server.ts @@ -0,0 +1,13 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listAgentActions } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform, url }) => { + if (!locals.user || !platform?.env?.DB) return json([], { status: 401 }); + + const limit = parseInt(url.searchParams.get('limit') || '50'); + const offset = parseInt(url.searchParams.get('offset') || '0'); + + const actions = await listAgentActions(platform.env.DB, { limit, offset }); + return json(actions); +}; diff --git a/pilot/src/routes/api/chat/stream/+server.ts b/pilot/src/routes/api/chat/stream/+server.ts new file mode 100644 index 00000000..90f5345c --- /dev/null +++ b/pilot/src/routes/api/chat/stream/+server.ts @@ -0,0 +1,310 @@ +import type { RequestHandler } from './$types'; +import { getChat, createMessage, updateChat, listMessages, listTasks, createTask } from '$lib/server/db'; + +const SYSTEM_PROMPT = `You are Pilot, an AI assistant that helps users manage their tasks and work. You can: +- Create and manage tasks on a kanban board +- Help plan and break down work +- Answer questions about projects and code +- Provide status updates on running tasks + +You have access to these tools: +- create_task: Create a new task on the board +- list_tasks: List current tasks with optional status filter +- show_board: Show the current state of the kanban board + +Be concise and helpful. When the user asks you to do something, create a task for it. +When discussing tasks, reference them by their ID and title.`; + +function buildTools() { + return [ + { + name: 'create_task', + description: 'Create a new task on the kanban board', + input_schema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Task title' }, + body: { type: 'string', description: 'Task description/instructions' }, + type: { type: 'string', enum: ['code', 'writing', 'thinking'], description: 'Task type' }, + }, + required: ['title'], + }, + }, + { + name: 'list_tasks', + description: 'List current tasks, optionally filtered by status', + input_schema: { + type: 'object', + properties: { + status: { type: 'string', enum: ['backlog', 'queued', 'processing', 'blocked', 'done', 'failed'] }, + }, + }, + }, + { + name: 'show_board', + description: 'Show the current state of the kanban board with task counts per column', + input_schema: { type: 'object', properties: {} }, + }, + ]; +} + +async function handleToolCall( + toolName: string, + toolInput: Record, + db: D1Database, + userId: string, +): Promise { + switch (toolName) { + case 'create_task': { + const task = await createTask(db, userId, { + title: toolInput.title as string, + body: (toolInput.body as string) || '', + type: (toolInput.type as string) || 'code', + }); + return JSON.stringify({ success: true, task: { id: task.id, title: task.title, status: task.status } }); + } + case 'list_tasks': { + const tasks = await listTasks(db, userId, { + status: toolInput.status as string | undefined, + includeClosed: true, + }); + return JSON.stringify(tasks.map(t => ({ id: t.id, title: t.title, status: t.status, type: t.type }))); + } + case 'show_board': { + const tasks = await listTasks(db, userId, { includeClosed: true }); + const board: Record = {}; + for (const t of tasks) { + board[t.status] = (board[t.status] || 0) + 1; + } + return JSON.stringify({ columns: board, total: tasks.length }); + } + default: + return JSON.stringify({ error: `Unknown tool: ${toolName}` }); + } +} + +export const POST: RequestHandler = async ({ locals, platform, request }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); + } + + const { chat_id, content } = await request.json() as { chat_id: string; content: string }; + + // Verify chat ownership + const chat = await getChat(platform.env.DB, user.id, chat_id); + if (!chat) { + return new Response(JSON.stringify({ error: 'Chat not found' }), { status: 404 }); + } + + // Save user message + await createMessage(platform.env.DB, { chat_id, role: 'user', content }); + + // Update chat title from first message + const existingMessages = await listMessages(platform.env.DB, chat_id); + if (existingMessages.filter(m => m.role === 'user').length <= 1) { + const title = content.slice(0, 60) + (content.length > 60 ? '...' : ''); + await updateChat(platform.env.DB, user.id, chat_id, { title }); + } + + // Get API key + const apiKey = platform.env.ANTHROPIC_API_KEY; + if (!apiKey) { + // Return a helpful error as SSE + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const errorEvent = `data: ${JSON.stringify({ + type: 'content_block_delta', + delta: { text: 'ANTHROPIC_API_KEY is not configured. Add it as a secret in wrangler.jsonc or via `wrangler secret put ANTHROPIC_API_KEY`.' } + })}\n\n`; + controller.enqueue(encoder.encode(errorEvent)); + controller.enqueue(encoder.encode(`data: [DONE]\n\n`)); + controller.close(); + } + }); + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } + + // Build message history + type AnthropicContent = string | Array<{ type: string; text?: string; id?: string; name?: string; input?: unknown; tool_use_id?: string; content?: string }>; + type AnthropicMessage = { role: string; content: AnthropicContent }; + + const history: AnthropicMessage[] = existingMessages + .filter(m => m.role === 'user' || m.role === 'assistant') + .map(m => ({ role: m.role, content: m.content })); + + // Call Anthropic API with streaming + const db = platform.env.DB; + const modelId = chat.model_id || 'claude-sonnet-4-5-20250929'; + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + try { + let fullContent = ''; + let inputTokens = 0; + let outputTokens = 0; + + // Make the API call with tool loop + let messages: AnthropicMessage[] = [...history]; + let continueLoop = true; + + while (continueLoop) { + continueLoop = false; + + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: modelId, + max_tokens: 4096, + system: SYSTEM_PROMPT, + messages, + tools: buildTools(), + stream: true, + }), + }); + + if (!response.ok) { + const errText = await response.text(); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', error: `API error ${response.status}: ${errText}` })}\n\n`)); + break; + } + + const reader = response.body?.getReader(); + if (!reader) break; + + let currentToolName = ''; + let currentToolInput = ''; + let currentToolId = ''; + let hasToolUse = false; + let textBuffer = ''; + + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6).trim(); + if (data === '' || data === '[DONE]') continue; + + try { + const event = JSON.parse(data); + + if (event.type === 'content_block_start') { + if (event.content_block?.type === 'tool_use') { + hasToolUse = true; + currentToolName = event.content_block.name; + currentToolId = event.content_block.id; + currentToolInput = ''; + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'tool_use', name: currentToolName })}\n\n`)); + } + } else if (event.type === 'content_block_delta') { + if (event.delta?.type === 'text_delta') { + fullContent += event.delta.text; + textBuffer += event.delta.text; + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'content_block_delta', delta: { text: event.delta.text } })}\n\n`)); + } else if (event.delta?.type === 'input_json_delta') { + currentToolInput += event.delta.partial_json; + } + } else if (event.type === 'message_delta') { + if (event.usage) { + outputTokens += event.usage.output_tokens || 0; + } + } else if (event.type === 'message_start') { + if (event.message?.usage) { + inputTokens += event.message.usage.input_tokens || 0; + } + } + } catch { + // Skip malformed JSON + } + } + } + + // Handle tool calls + if (hasToolUse && currentToolName) { + let toolInput: Record = {}; + try { + toolInput = JSON.parse(currentToolInput || '{}'); + } catch { /* empty */ } + + const toolResult = await handleToolCall(currentToolName, toolInput, db, user.id); + + // Add assistant message with tool use and tool result to continue the loop + messages = [ + ...messages, + { + role: 'assistant' as const, + content: [ + ...(textBuffer ? [{ type: 'text' as const, text: textBuffer }] : []), + { type: 'tool_use' as const, id: currentToolId, name: currentToolName, input: toolInput }, + ], + }, + { + role: 'user' as const, + content: [ + { type: 'tool_result' as const, tool_use_id: currentToolId, content: toolResult }, + ], + }, + ]; + textBuffer = ''; + continueLoop = true; + } + } + + // Save assistant message + if (fullContent) { + const msg = await createMessage(db, { + chat_id, + role: 'assistant', + content: fullContent, + model_id: modelId, + input_tokens: inputTokens, + output_tokens: outputTokens, + }); + + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + type: 'message_complete', + message: { id: msg.id, input_tokens: inputTokens, output_tokens: outputTokens, model_id: modelId }, + })}\n\n`)); + } + + controller.enqueue(encoder.encode(`data: [DONE]\n\n`)); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error'; + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); +}; diff --git a/pilot/src/routes/api/chats/+server.ts b/pilot/src/routes/api/chats/+server.ts new file mode 100644 index 00000000..caebb1d9 --- /dev/null +++ b/pilot/src/routes/api/chats/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listChats, createChat } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json([], { status: 401 }); + + const chats = await listChats(platform.env.DB, user.id); + return json(chats); +}; + +export const POST: RequestHandler = async ({ locals, platform, request }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); + + const data = await request.json() as { title?: string; model_id?: string }; + const chat = await createChat(platform.env.DB, user.id, data); + return json(chat, { status: 201 }); +}; diff --git a/pilot/src/routes/api/chats/[id]/+server.ts b/pilot/src/routes/api/chats/[id]/+server.ts new file mode 100644 index 00000000..286ab0bc --- /dev/null +++ b/pilot/src/routes/api/chats/[id]/+server.ts @@ -0,0 +1,30 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getChat, updateChat, deleteChat } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform, params }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); + + const chat = await getChat(platform.env.DB, user.id, params.id); + if (!chat) return json({ error: 'Not found' }, { status: 404 }); + return json(chat); +}; + +export const PUT: RequestHandler = async ({ locals, platform, params, request }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); + + const data = await request.json() as { title?: string; model_id?: string }; + const chat = await updateChat(platform.env.DB, user.id, params.id, data); + if (!chat) return json({ error: 'Not found' }, { status: 404 }); + return json(chat); +}; + +export const DELETE: RequestHandler = async ({ locals, platform, params }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); + + await deleteChat(platform.env.DB, user.id, params.id); + return new Response(null, { status: 204 }); +}; diff --git a/pilot/src/routes/api/chats/[id]/messages/+server.ts b/pilot/src/routes/api/chats/[id]/messages/+server.ts new file mode 100644 index 00000000..b5bcbf8c --- /dev/null +++ b/pilot/src/routes/api/chats/[id]/messages/+server.ts @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getChat, listMessages } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform, params }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json([], { status: 401 }); + + // Verify chat belongs to user + const chat = await getChat(platform.env.DB, user.id, params.id); + if (!chat) return json({ error: 'Not found' }, { status: 404 }); + + const messages = await listMessages(platform.env.DB, params.id); + return json(messages); +}; diff --git a/pilot/src/routes/api/integrations/+server.ts b/pilot/src/routes/api/integrations/+server.ts new file mode 100644 index 00000000..30217bef --- /dev/null +++ b/pilot/src/routes/api/integrations/+server.ts @@ -0,0 +1,10 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listIntegrations } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform }) => { + if (!locals.user || !platform?.env?.DB) return json([], { status: 401 }); + + const integrations = await listIntegrations(platform.env.DB); + return json(integrations); +}; diff --git a/pilot/src/routes/api/models/+server.ts b/pilot/src/routes/api/models/+server.ts new file mode 100644 index 00000000..aecb5138 --- /dev/null +++ b/pilot/src/routes/api/models/+server.ts @@ -0,0 +1,10 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listModels } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ platform }) => { + if (!platform?.env?.DB) return json([]); + + const models = await listModels(platform.env.DB); + return json(models); +}; diff --git a/pilot/src/routes/api/projects/+server.ts b/pilot/src/routes/api/projects/+server.ts index 9984f5be..e965c31f 100644 --- a/pilot/src/routes/api/projects/+server.ts +++ b/pilot/src/routes/api/projects/+server.ts @@ -14,12 +14,12 @@ export const GET: RequestHandler = async ({ locals, platform }) => { export const POST: RequestHandler = async ({ request, locals, platform }) => { const user = locals.user!; const db = platform!.env.DB; - const data = await request.json(); + const data = await request.json() as { name?: string; path?: string; aliases?: string; instructions?: string; color?: string }; if (!data.name) { return json({ error: 'Name is required' }, { status: 400 }); } - const project = await createProject(db, user.id, data); + const project = await createProject(db, user.id, { name: data.name!, path: data.path || '', ...data }); return json(project, { status: 201 }); }; diff --git a/pilot/src/routes/api/projects/[id]/+server.ts b/pilot/src/routes/api/projects/[id]/+server.ts index 41c9fb9d..0ce5c83a 100644 --- a/pilot/src/routes/api/projects/[id]/+server.ts +++ b/pilot/src/routes/api/projects/[id]/+server.ts @@ -7,7 +7,7 @@ export const PUT: RequestHandler = async ({ params, request, locals, platform }) const user = locals.user!; const db = platform!.env.DB; const projectId = parseInt(params.id); - const data = await request.json(); + const data = await request.json() as { name?: string; path?: string; aliases?: string; instructions?: string; color?: string }; const project = await updateProject(db, user.id, projectId, data); if (!project) { diff --git a/pilot/src/routes/api/sandboxes/+server.ts b/pilot/src/routes/api/sandboxes/+server.ts new file mode 100644 index 00000000..6ff3b09d --- /dev/null +++ b/pilot/src/routes/api/sandboxes/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listSandboxes, createSandbox } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform }) => { + if (!locals.user || !platform?.env?.DB) return json([], { status: 401 }); + + const sandboxes = await listSandboxes(platform.env.DB); + return json(sandboxes); +}; + +export const POST: RequestHandler = async ({ locals, platform, request }) => { + if (!locals.user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); + + const data = await request.json() as { name?: string; provider?: string }; + const sandbox = await createSandbox(platform.env.DB, data); + return json(sandbox, { status: 201 }); +}; diff --git a/pilot/src/routes/api/settings/+server.ts b/pilot/src/routes/api/settings/+server.ts index 6b881772..671f1bba 100644 --- a/pilot/src/routes/api/settings/+server.ts +++ b/pilot/src/routes/api/settings/+server.ts @@ -14,7 +14,7 @@ export const GET: RequestHandler = async ({ locals, platform }) => { export const PUT: RequestHandler = async ({ request, locals, platform }) => { const user = locals.user!; const db = platform!.env.DB; - const data = await request.json(); + const data = await request.json() as Record; await updateSettings(db, user.id, data); return json({ success: true }); diff --git a/pilot/src/routes/api/tasks/+server.ts b/pilot/src/routes/api/tasks/+server.ts index 390a61c3..8e7e5d65 100644 --- a/pilot/src/routes/api/tasks/+server.ts +++ b/pilot/src/routes/api/tasks/+server.ts @@ -23,7 +23,7 @@ export const POST: RequestHandler = async ({ request, locals, platform }) => { const user = locals.user!; const db = platform!.env.DB; - const data = await request.json(); + const data = await request.json() as { title?: string; body?: string; type?: string; project?: string }; if (!data.title) { return json({ error: 'Title is required' }, { status: 400 }); diff --git a/pilot/src/routes/api/tasks/[id]/+server.ts b/pilot/src/routes/api/tasks/[id]/+server.ts index cc6c5b99..b0ae25b8 100644 --- a/pilot/src/routes/api/tasks/[id]/+server.ts +++ b/pilot/src/routes/api/tasks/[id]/+server.ts @@ -22,7 +22,7 @@ export const PUT: RequestHandler = async ({ params, request, locals, platform }) const db = platform!.env.DB; const taskId = parseInt(params.id); - const data = await request.json(); + const data = await request.json() as { title?: string; body?: string; status?: import('$lib/types').TaskStatus; type?: string; project?: string }; const task = await updateTask(db, user.id, taskId, data); if (!task) { return json({ error: 'Task not found' }, { status: 404 }); diff --git a/pilot/src/routes/api/tasks/[id]/file/+server.ts b/pilot/src/routes/api/tasks/[id]/file/+server.ts new file mode 100644 index 00000000..eb3a1b49 --- /dev/null +++ b/pilot/src/routes/api/tasks/[id]/file/+server.ts @@ -0,0 +1,25 @@ +import { error, text } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getTask } from '$lib/server/db'; + +// GET /api/tasks/:id/file?path=... +// Returns file content from a task's worktree +export const GET: RequestHandler = async ({ params, url, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + const filePath = url.searchParams.get('path'); + + if (!filePath) throw error(400, 'Missing path parameter'); + + const task = await getTask(db, user.id, taskId); + if (!task) throw error(404, 'Task not found'); + + if (!task.worktree_path) { + throw error(404, 'Task has no worktree'); + } + + // TODO: When sandbox integration is ready, fetch file from the sandbox/worktree + // For now return a placeholder + return text(`// File: ${filePath}\n// Content will be available when sandbox integration is complete.\n// Worktree: ${task.worktree_path}`); +}; diff --git a/pilot/src/routes/api/workspaces/+server.ts b/pilot/src/routes/api/workspaces/+server.ts new file mode 100644 index 00000000..b159f1bb --- /dev/null +++ b/pilot/src/routes/api/workspaces/+server.ts @@ -0,0 +1,23 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listWorkspaces, createWorkspace } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const workspaces = await listWorkspaces(db, user.id); + return json(workspaces); +}; + +export const POST: RequestHandler = async ({ request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const data = await request.json() as { name?: string }; + + if (!data.name) { + return json({ error: 'Name is required' }, { status: 400 }); + } + + const workspace = await createWorkspace(db, user.id, { name: data.name }); + return json(workspace, { status: 201 }); +}; diff --git a/pilot/src/routes/api/workspaces/[id]/+server.ts b/pilot/src/routes/api/workspaces/[id]/+server.ts new file mode 100644 index 00000000..9e1f5477 --- /dev/null +++ b/pilot/src/routes/api/workspaces/[id]/+server.ts @@ -0,0 +1,37 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getWorkspace, updateWorkspace, deleteWorkspace } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ params, platform }) => { + const db = platform!.env.DB; + const workspace = await getWorkspace(db, params.id); + if (!workspace) { + return json({ error: 'Workspace not found' }, { status: 404 }); + } + return json(workspace); +}; + +export const PUT: RequestHandler = async ({ params, request, platform }) => { + const db = platform!.env.DB; + const data = await request.json() as { name?: string; autonomous_enabled?: boolean; weekly_budget_cents?: number }; + + const workspace = await updateWorkspace(db, params.id, data); + if (!workspace) { + return json({ error: 'Workspace not found' }, { status: 404 }); + } + return json(workspace); +}; + +export const DELETE: RequestHandler = async ({ params, platform }) => { + const db = platform!.env.DB; + + if (params.id === 'default') { + return json({ error: 'Cannot delete the default workspace' }, { status: 400 }); + } + + const deleted = await deleteWorkspace(db, params.id); + if (!deleted) { + return json({ error: 'Workspace not found' }, { status: 404 }); + } + return new Response(null, { status: 204 }); +}; diff --git a/pilot/static/icon.svg b/pilot/static/icon.svg new file mode 100644 index 00000000..30b2ef13 --- /dev/null +++ b/pilot/static/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/pilot/tailwind.config.js b/pilot/tailwind.config.js deleted file mode 100644 index 7c6bbe55..00000000 --- a/pilot/tailwind.config.js +++ /dev/null @@ -1,46 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: ['./src/**/*.{html,js,svelte,ts}'], - darkMode: 'class', - theme: { - extend: { - colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - }, - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', - }, - }, - }, - plugins: [], -}; diff --git a/pilot/vite.config.ts b/pilot/vite.config.ts index 38b255b7..8530af6a 100644 --- a/pilot/vite.config.ts +++ b/pilot/vite.config.ts @@ -1,8 +1,9 @@ import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit()], + plugins: [tailwindcss(), sveltekit()], resolve: { conditions: ['workerd', 'worker', 'browser'], }, diff --git a/pilot/wrangler.jsonc b/pilot/wrangler.jsonc index b1693a21..b2952156 100644 --- a/pilot/wrangler.jsonc +++ b/pilot/wrangler.jsonc @@ -45,5 +45,15 @@ "vars": { // Set to "production" when deploying via Cloudflare dashboard or wrangler secret "ENVIRONMENT": "development" - } + }, + // Secrets (set via `wrangler secret put`): + // ANTHROPIC_API_KEY - Required for LLM chat streaming + // GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET - For Google OAuth + // GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET - For GitHub OAuth + "r2_buckets": [ + { + "binding": "STORAGE", + "bucket_name": "taskyou-pilot-storage" + } + ] } From 2bf613830afa8ad5b09e8503974ffa7e8fa4380b Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 17 Feb 2026 15:37:13 -0600 Subject: [PATCH 06/16] wip --- pilot/Dockerfile | 23 -- pilot/Dockerfile.sandbox | 11 + pilot/package.json | 14 +- pilot/scripts/postbuild.js | 47 +++ pilot/src/app.d.ts | 3 +- pilot/src/lib/api/client.ts | 53 +-- pilot/src/lib/components/ApprovalsPage.svelte | 2 +- pilot/src/lib/components/AuditLogPage.svelte | 109 +----- pilot/src/lib/components/ChatPanel.svelte | 123 +++---- .../src/lib/components/CommandPalette.svelte | 79 +++-- pilot/src/lib/components/Dashboard.svelte | 40 +-- pilot/src/lib/components/LoginPage.svelte | 2 +- pilot/src/lib/components/NewTaskDialog.svelte | 1 - pilot/src/lib/components/ProjectDialog.svelte | 14 +- pilot/src/lib/components/ProjectsPage.svelte | 154 +++++++++ pilot/src/lib/components/SandboxesPage.svelte | 155 --------- pilot/src/lib/components/SettingsPage.svelte | 6 +- pilot/src/lib/components/Sidebar.svelte | 35 +- pilot/src/lib/components/TaskBoard.svelte | 38 +-- pilot/src/lib/components/TaskCard.svelte | 14 +- pilot/src/lib/components/TaskDetail.svelte | 36 +- pilot/src/lib/server/agent.ts | 268 +++++++++++++++ pilot/src/lib/server/db.ts | 231 ++++--------- pilot/src/lib/server/sandbox.ts | 123 ------- pilot/src/lib/server/workflow.ts | 100 ++++++ pilot/src/lib/stores/agent-ws.ts | 49 +++ pilot/src/lib/stores/agent.svelte.ts | 195 +++++++++++ pilot/src/lib/stores/chat.svelte.ts | 209 +++++------- pilot/src/lib/stores/tasks.svelte.ts | 22 +- pilot/src/lib/types.ts | 97 ++---- pilot/src/routes/+page.svelte | 127 ++++++- pilot/src/routes/api/agent-actions/+server.ts | 13 - pilot/src/routes/api/chat/stream/+server.ts | 310 ------------------ pilot/src/routes/api/projects/+server.ts | 22 +- pilot/src/routes/api/projects/[id]/+server.ts | 37 +-- .../routes/api/sandbox/terminal/+server.ts | 13 - pilot/src/routes/api/sandboxes/+server.ts | 18 - pilot/src/routes/api/tasks/+server.ts | 26 +- pilot/src/routes/api/tasks/[id]/+server.ts | 2 +- .../routes/api/tasks/[id]/close/+server.ts | 19 -- .../src/routes/api/tasks/[id]/file/+server.ts | 25 -- .../routes/api/tasks/[id]/queue/+server.ts | 52 --- .../routes/api/tasks/[id]/retry/+server.ts | 27 -- pilot/vite.config.ts | 2 +- pilot/worker-entry.js | 134 ++++++++ pilot/wrangler.jsonc | 47 +-- 46 files changed, 1534 insertions(+), 1593 deletions(-) delete mode 100644 pilot/Dockerfile create mode 100644 pilot/Dockerfile.sandbox create mode 100644 pilot/scripts/postbuild.js create mode 100644 pilot/src/lib/components/ProjectsPage.svelte delete mode 100644 pilot/src/lib/components/SandboxesPage.svelte create mode 100644 pilot/src/lib/server/agent.ts delete mode 100644 pilot/src/lib/server/sandbox.ts create mode 100644 pilot/src/lib/server/workflow.ts create mode 100644 pilot/src/lib/stores/agent-ws.ts create mode 100644 pilot/src/lib/stores/agent.svelte.ts delete mode 100644 pilot/src/routes/api/agent-actions/+server.ts delete mode 100644 pilot/src/routes/api/chat/stream/+server.ts delete mode 100644 pilot/src/routes/api/sandbox/terminal/+server.ts delete mode 100644 pilot/src/routes/api/sandboxes/+server.ts delete mode 100644 pilot/src/routes/api/tasks/[id]/close/+server.ts delete mode 100644 pilot/src/routes/api/tasks/[id]/file/+server.ts delete mode 100644 pilot/src/routes/api/tasks/[id]/queue/+server.ts delete mode 100644 pilot/src/routes/api/tasks/[id]/retry/+server.ts create mode 100644 pilot/worker-entry.js diff --git a/pilot/Dockerfile b/pilot/Dockerfile deleted file mode 100644 index a42b5029..00000000 --- a/pilot/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM node:22-slim - -RUN apt-get update && apt-get install -y \ - git \ - curl \ - python3 \ - python3-pip \ - tmux \ - sqlite3 \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /workspace - -# Create a non-root user for running tasks -RUN useradd -m -s /bin/bash taskyou - -# Copy the task runner script -COPY sandbox-init.sh /usr/local/bin/sandbox-init.sh -RUN chmod +x /usr/local/bin/sandbox-init.sh - -USER taskyou - -ENTRYPOINT ["/usr/local/bin/sandbox-init.sh"] diff --git a/pilot/Dockerfile.sandbox b/pilot/Dockerfile.sandbox new file mode 100644 index 00000000..17cf0e62 --- /dev/null +++ b/pilot/Dockerfile.sandbox @@ -0,0 +1,11 @@ +FROM docker.io/cloudflare/sandbox:0.7.4 + +# Install Claude Code globally (persists in image) +RUN npm install -g @anthropic-ai/claude-code + +# Set up git config +RUN git config --global user.name "TaskYou Agent" && \ + git config --global user.email "agent@taskyou.dev" + +# Initialize workspace +RUN mkdir -p /root/worktrees diff --git a/pilot/package.json b/pilot/package.json index e71bb65f..6c6c7194 100644 --- a/pilot/package.json +++ b/pilot/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "vite build", + "build": "vite build && node scripts/postbuild.js", "preview": "wrangler dev", "deploy": "npm run build && wrangler deploy", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -13,7 +13,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20250129.0", - "@sveltejs/adapter-cloudflare": "^4.0.0", + "@sveltejs/adapter-cloudflare": "^7.2.7", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tailwindcss/vite": "^4.0.0", @@ -23,12 +23,16 @@ "typescript": "^5.0.0", "vite": "^5.0.0", "vitest": "^2.0.0", - "wrangler": "^3.28.4" + "wrangler": "^4.65.0" }, "dependencies": { - "@cloudflare/sandbox": "^0.4.0", + "@ai-sdk/anthropic": "^3.0.44", + "@cloudflare/ai-chat": "^0.1.1", + "agents": "^0.5.0", + "ai": "^6.0.87", "basecoat-css": "^0.3.11", "lucide-svelte": "^0.460.0", - "marked": "^17.0.2" + "marked": "^17.0.2", + "zod": "^4.3.6" } } diff --git a/pilot/scripts/postbuild.js b/pilot/scripts/postbuild.js new file mode 100644 index 00000000..c815590b --- /dev/null +++ b/pilot/scripts/postbuild.js @@ -0,0 +1,47 @@ +// Post-build: Wrap SvelteKit worker with custom entry point +// - Adds routeAgentRequest() for agent WebSocket/HTTP handling +// - Re-exports TaskYouAgent and TaskExecutionWorkflow for wrangler DO/Workflow bindings +// - SvelteKit handles everything else (auth, CRUD, static assets) + +import { readFileSync, writeFileSync, existsSync } from 'fs'; + +const workerFile = 'worker-entry.js'; + +if (!existsSync(workerFile)) { + console.error(`✗ ${workerFile} not found - did vite build run?`); + process.exit(1); +} + +let content = readFileSync(workerFile, 'utf8'); + +// Check if already patched +if (content.includes('routeAgentRequest')) { + console.log('✓ worker-entry.js already patched'); + process.exit(0); +} + +// Add agent imports at the top of the file +const agentImports = ` +import { routeAgentRequest } from "agents"; +`; + +content = agentImports + content; + +// Wrap the existing fetch handler with agent routing +content = content.replace( + 'async fetch(req, env2, ctx) {', + `async fetch(req, env2, ctx) { + // Route agent WebSocket/HTTP requests before SvelteKit + const agentResponse = await routeAgentRequest(req, env2); + if (agentResponse) return agentResponse;` +); + +// Add DO and Workflow class re-exports at the end +content += ` +// Re-export agent classes for Durable Object and Workflow bindings +export { TaskYouAgent } from "./src/lib/server/agent.ts"; +export { TaskExecutionWorkflow } from "./src/lib/server/workflow.ts"; +`; + +writeFileSync(workerFile, content); +console.log('✓ Patched worker-entry.js with routeAgentRequest + agent exports'); diff --git a/pilot/src/app.d.ts b/pilot/src/app.d.ts index 7cf6426b..b8a88de8 100644 --- a/pilot/src/app.d.ts +++ b/pilot/src/app.d.ts @@ -10,7 +10,8 @@ declare global { interface Platform { env: { DB: D1Database; - SANDBOX: DurableObjectNamespace; + TASKYOU_AGENT: DurableObjectNamespace; + TASK_WORKFLOW: Workflow; SESSIONS: KVNamespace; STORAGE?: R2Bucket; GOOGLE_CLIENT_ID?: string; diff --git a/pilot/src/lib/api/client.ts b/pilot/src/lib/api/client.ts index 954638b0..1541e2ee 100644 --- a/pilot/src/lib/api/client.ts +++ b/pilot/src/lib/api/client.ts @@ -10,9 +10,7 @@ import type { Chat, Message, Model, - AgentAction, Integration, - Sandbox, Workspace, } from '$lib/types'; @@ -46,10 +44,10 @@ export const auth = { // Tasks API export const tasks = { - list: (options?: { status?: string; project?: string; type?: string; all?: boolean }) => { + list: (options?: { status?: string; project_id?: string; type?: string; all?: boolean }) => { const params = new URLSearchParams(); if (options?.status) params.set('status', options.status); - if (options?.project) params.set('project', options.project); + if (options?.project_id) params.set('project_id', options.project_id); if (options?.type) params.set('type', options.type); if (options?.all) params.set('all', 'true'); const query = params.toString(); @@ -68,15 +66,6 @@ export const tasks = { }), delete: (id: number) => fetchJSON(`/tasks/${id}`, { method: 'DELETE' }), - queue: (id: number) => - fetchJSON(`/tasks/${id}/queue`, { method: 'POST' }), - retry: (id: number, feedback?: string) => - fetchJSON(`/tasks/${id}/retry`, { - method: 'POST', - body: JSON.stringify({ feedback }), - }), - close: (id: number) => - fetchJSON(`/tasks/${id}/close`, { method: 'POST' }), getLogs: (id: number, limit?: number) => { const params = limit ? `?limit=${limit}` : ''; return fetchJSON(`/tasks/${id}/logs${params}`); @@ -86,17 +75,18 @@ export const tasks = { // Projects API export const projects = { list: () => fetchJSON('/projects'), - create: (data: CreateProjectRequest) => + get: (id: string) => fetchJSON(`/projects/${id}`), + create: (data?: CreateProjectRequest) => fetchJSON('/projects', { method: 'POST', - body: JSON.stringify(data), + body: JSON.stringify(data || {}), }), - update: (id: number, data: UpdateProjectRequest) => + update: (id: string, data: UpdateProjectRequest) => fetchJSON(`/projects/${id}`, { method: 'PUT', body: JSON.stringify(data), }), - delete: (id: number) => + delete: (id: string) => fetchJSON(`/projects/${id}`, { method: 'DELETE' }), }; @@ -104,7 +94,7 @@ export const projects = { export const chats = { list: () => fetchJSON('/chats'), get: (id: string) => fetchJSON(`/chats/${id}`), - create: (data?: { title?: string; model_id?: string }) => + create: (data?: { title?: string; model_id?: string; project_id?: string }) => fetchJSON('/chats', { method: 'POST', body: JSON.stringify(data || {}), @@ -128,38 +118,11 @@ export const models = { list: () => fetchJSON('/models'), }; -// Agent Actions API -export const agentActions = { - list: (options?: { limit?: number; offset?: number }) => { - const params = new URLSearchParams(); - if (options?.limit) params.set('limit', String(options.limit)); - if (options?.offset) params.set('offset', String(options.offset)); - const query = params.toString(); - return fetchJSON(`/agent-actions${query ? `?${query}` : ''}`); - }, -}; - // Integrations API export const integrations = { list: () => fetchJSON('/integrations'), }; -// Sandboxes API -export const sandboxes = { - list: () => fetchJSON('/sandboxes'), - create: (data?: { name?: string }) => - fetchJSON('/sandboxes', { - method: 'POST', - body: JSON.stringify(data || {}), - }), - start: (id: string) => - fetchJSON(`/sandboxes/${id}/start`, { method: 'POST' }), - stop: (id: string) => - fetchJSON(`/sandboxes/${id}/stop`, { method: 'POST' }), - delete: (id: string) => - fetchJSON(`/sandboxes/${id}`, { method: 'DELETE' }), -}; - // Workspaces API export const workspaces = { list: () => fetchJSON('/workspaces'), diff --git a/pilot/src/lib/components/ApprovalsPage.svelte b/pilot/src/lib/components/ApprovalsPage.svelte index 1a3f4dd7..b0a29180 100644 --- a/pilot/src/lib/components/ApprovalsPage.svelte +++ b/pilot/src/lib/components/ApprovalsPage.svelte @@ -44,7 +44,7 @@ {/if}
{task.type} - {task.project} + {task.project_id}
diff --git a/pilot/src/lib/components/AuditLogPage.svelte b/pilot/src/lib/components/AuditLogPage.svelte index 749e7abc..c32862cb 100644 --- a/pilot/src/lib/components/AuditLogPage.svelte +++ b/pilot/src/lib/components/AuditLogPage.svelte @@ -1,34 +1,5 @@
@@ -38,78 +9,12 @@

Audit Log

- {#if loading} -
-
+
+
+
- {:else if actions.length === 0} -
-
- -
-

No Actions Yet

-

Agent actions will appear here as they execute

-
- {:else} -
- - - - - - - - - - - - {#each actions as action} - - - - - - - - {/each} - -
ActionRiskCostStatusTime
-
{action.action_type}
-
{action.description}
-
- {#if action.risk_level === 'high'} - - - {action.risk_level} - - {:else if action.risk_level === 'medium'} - - - {action.risk_level} - - {:else} - - - {action.risk_level} - - {/if} - - {#if action.cost_cents > 0} - - - {(action.cost_cents / 100).toFixed(2)} - - {:else} - - - {/if} - - - {action.status.replace('_', ' ')} - - - {new Date(action.created_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} -
-
- {/if} +

Coming Soon

+

Agent actions will appear here once workflow execution is enabled

+
diff --git a/pilot/src/lib/components/ChatPanel.svelte b/pilot/src/lib/components/ChatPanel.svelte index 984820c6..a2c6c51c 100644 --- a/pilot/src/lib/components/ChatPanel.svelte +++ b/pilot/src/lib/components/ChatPanel.svelte @@ -1,9 +1,9 @@ @@ -105,24 +112,12 @@

Pilot Chat

-
- - -
+ Claude Sonnet 4.5
- {#if chatState.messages.length === 0 && !chatState.streaming} + {#if chatState.agentMessages.length === 0 && !agentChatStream.streaming}
@@ -136,7 +131,7 @@ {#each quickActions as action}
{:else} - {#each chatState.messages as message (message.id)} -
+ {#each chatState.agentMessages as message (message.id)} +
{#if message.role === 'user'} @@ -164,11 +159,6 @@
{message.role === 'user' ? 'You' : 'Pilot'} - {#if message.input_tokens || message.output_tokens} - - {formatTokens(message.input_tokens)}in / {formatTokens(message.output_tokens)}out - - {/if}
{#if message.role === 'assistant'}
@@ -179,12 +169,28 @@ {message.content}
{/if} + + + {#if message.toolInvocations?.length} +
+ {#each message.toolInvocations as tool} +
+ {tool.toolName} + {#if tool.state === 'result'} + done + {:else} + running... + {/if} +
+ {/each} +
+ {/if}
{/each} - {#if chatState.streaming} + {#if agentChatStream.streaming}
@@ -196,8 +202,8 @@ Pilot
- {#if chatState.streamingContent} -
{@html renderMarkdown(chatState.streamingContent)}
+ {#if agentChatStream.streamingContent} +
{@html renderMarkdown(agentChatStream.streamingContent)}
{:else}
@@ -224,14 +230,14 @@ placeholder="Ask Pilot anything..." rows="1" class="input flex-1 text-sm h-9 resize-none min-h-[36px] max-h-[200px]" - disabled={chatState.streaming} + disabled={agentChatStream.streaming} >
- diff --git a/pilot/src/lib/components/CommandPalette.svelte b/pilot/src/lib/components/CommandPalette.svelte index b5c534f5..494c9dbc 100644 --- a/pilot/src/lib/components/CommandPalette.svelte +++ b/pilot/src/lib/components/CommandPalette.svelte @@ -1,6 +1,10 @@ diff --git a/pilot/src/lib/components/NewTaskDialog.svelte b/pilot/src/lib/components/NewTaskDialog.svelte index 7c6bd2b6..2184a11f 100644 --- a/pilot/src/lib/components/NewTaskDialog.svelte +++ b/pilot/src/lib/components/NewTaskDialog.svelte @@ -47,7 +47,6 @@ title: title.trim(), body: body.trim() || undefined, type, - project, }); dialogEl.close(); } catch (error) { diff --git a/pilot/src/lib/components/ProjectDialog.svelte b/pilot/src/lib/components/ProjectDialog.svelte index ddc0282d..097308e0 100644 --- a/pilot/src/lib/components/ProjectDialog.svelte +++ b/pilot/src/lib/components/ProjectDialog.svelte @@ -4,7 +4,7 @@ interface Props { project?: Project; - onSubmit: (data: { name: string; path: string; aliases?: string; instructions?: string; color?: string }) => Promise; + onSubmit: (data: { name: string; instructions?: string; color?: string }) => Promise; onDelete?: () => Promise; onClose: () => void; } @@ -12,8 +12,6 @@ let { project, onSubmit, onDelete, onClose }: Props = $props(); let name = $state(project?.name || ''); - let path = $state(project?.path || ''); - let aliases = $state(project?.aliases || ''); let instructions = $state(project?.instructions || ''); let color = $state(project?.color || '#888888'); let submitting = $state(false); @@ -41,7 +39,7 @@ submitting = true; try { - await onSubmit({ name: name.trim(), path: path.trim(), aliases: aliases.trim(), instructions: instructions.trim(), color }); + await onSubmit({ name: name.trim(), instructions: instructions.trim(), color }); dialogEl.close(); } catch (err) { console.error(err); @@ -80,14 +78,6 @@
-
- - -
-
- - -
+ + +
+
From f4fa309cf0837ee6e9ba01c8b2992420e1e4414a Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 21 Feb 2026 08:10:17 -0600 Subject: [PATCH 10/16] feat(task-chat): integrate TaskChat into TaskDetail modal Co-Authored-By: Claude Sonnet 4.6 --- pilot/src/lib/components/TaskDetail.svelte | 236 +++++++++++++-------- 1 file changed, 145 insertions(+), 91 deletions(-) diff --git a/pilot/src/lib/components/TaskDetail.svelte b/pilot/src/lib/components/TaskDetail.svelte index 0bcf5904..98838526 100644 --- a/pilot/src/lib/components/TaskDetail.svelte +++ b/pilot/src/lib/components/TaskDetail.svelte @@ -1,14 +1,15 @@ @@ -67,7 +48,7 @@

Projects

- @@ -88,7 +69,7 @@

No Projects

Create a project to organize your tasks

- @@ -103,21 +84,15 @@
- {#if editingProject === project.id} -
{ e.preventDefault(); saveEdit(project.id); }} class="flex items-center gap-2"> - - - -
- {:else} -

{project.name}

+

{project.name}

+ {#if project.github_repo} +

+ + {project.github_repo} + {#if project.github_branch} + ({project.github_branch}) + {/if} +

{/if} {#if project.instructions}

{project.instructions}

@@ -128,18 +103,16 @@
- {#if editingProject !== project.id} - - {/if} +
+ +{#if showNewProject} + (showNewProject = false)} + /> +{/if} + +{#if editingProject} + (editingProject = null)} + /> +{/if} diff --git a/pilot/src/lib/components/SettingsPage.svelte b/pilot/src/lib/components/SettingsPage.svelte index 2e614323..1554e280 100644 --- a/pilot/src/lib/components/SettingsPage.svelte +++ b/pilot/src/lib/components/SettingsPage.svelte @@ -45,12 +45,12 @@ finally { saving = false; } } - async function handleCreateProject(data: { name: string; instructions?: string; color?: string }) { + async function handleCreateProject(data: { name: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string }) { const newProject = await projectsApi.create(data); projects = [...projects, newProject]; } - async function handleUpdateProject(data: { name?: string; instructions?: string; color?: string }) { + async function handleUpdateProject(data: { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string }) { if (!editingProject) return; const updated = await projectsApi.update(editingProject.id, data); projects = projects.map((p) => (p.id === editingProject!.id ? updated : p)); diff --git a/pilot/src/lib/components/Sidebar.svelte b/pilot/src/lib/components/Sidebar.svelte index 7ac89a63..4cada786 100644 --- a/pilot/src/lib/components/Sidebar.svelte +++ b/pilot/src/lib/components/Sidebar.svelte @@ -1,12 +1,13 @@ @@ -105,98 +166,154 @@
+
- +
+ +
+
-
- -
-
-
- -
- - - -

Chats

-

+
-

Projects

+ {#if scratchChats.length > 0} + + {/if} +
+ +
@@ -244,3 +361,19 @@
+ +{#if showNewProject} + (showNewProject = false)} + /> +{/if} + +{#if editingProject} + (editingProject = null)} + /> +{/if} diff --git a/pilot/src/lib/components/TaskBoard.svelte b/pilot/src/lib/components/TaskBoard.svelte index 08dca0cb..4c6ad479 100644 --- a/pilot/src/lib/components/TaskBoard.svelte +++ b/pilot/src/lib/components/TaskBoard.svelte @@ -176,6 +176,22 @@ setFocus(navState.focusedColumn, Math.min(navState.focusedRow, Math.max(0, remaining.length - 2))); } + // Focus effect — uses DOM directly because Svelte 5 keyed {#each} doesn't + // re-render items when only external $state (navState) changes. + $effect(() => { + const col = navState.focusedColumn; + const row = navState.focusedRow; + // Clear previous focus + document.querySelectorAll('.focused-card').forEach(el => el.classList.remove('focused-card')); + // Apply to new target + const colEls = document.querySelectorAll('.kanban-column'); + const target = colEls[col]?.querySelectorAll('[data-card]')?.[row]; + if (target) { + target.classList.add('focused-card'); + target.scrollIntoView({ block: 'nearest' }); + } + }); + // Keyboard navigation function handleKeydown(e: KeyboardEvent) { const target = e.target as HTMLElement; @@ -300,7 +316,7 @@
-

Tasks

+

Tasks

{#if getInProgressTasks().length > 0} @@ -316,7 +332,7 @@ {/if}
- @@ -324,67 +340,65 @@ {/if} -
+
{#each columns as col, colIdx} {@const columnTasks = getColumnTasks(col.key)}
handleDragOver(e, col.key)} ondragleave={handleDragLeave} ondrop={(e) => handleDrop(e, col.targetStatus)} >
0 ? col.color : undefined} - style:border-bottom-width={columnTasks.length > 0 ? '2px' : '1px'} + class="kanban-column-header" + data-has-tasks={columnTasks.length > 0 ? '' : undefined} + style:--col-color={col.color} >
- -

{col.title}

+ +

{col.title}

- + {columnTasks.length}
-
+
{#each columnTasks as task, rowIdx (task.id)}
handleDragStart(e, task)} ondragend={handleDragEnd} oncontextmenu={(e) => handleContextMenu(e, task)} - class="rounded-lg" - class:ring-2={navState.focusedColumn === colIdx && navState.focusedRow === rowIdx && !isSelected(task.id)} - class:ring-primary={navState.focusedColumn === colIdx && navState.focusedRow === rowIdx && !isSelected(task.id)} - class:ring-2-selected={isSelected(task.id)} + class="rounded-lg transition-colors" + class:selected-card={selectedIds.has(task.id)} class:opacity-50={draggedTask?.id === task.id} >
{/each} {#if columnTasks.length === 0} -
- -

{col.emptyMessage}

+
+

{col.emptyMessage}

{/if}
{#if col.showAdd} -
-
diff --git a/pilot/src/lib/components/TaskCard.svelte b/pilot/src/lib/components/TaskCard.svelte index d5bcd90a..6af0b4e8 100644 --- a/pilot/src/lib/components/TaskCard.svelte +++ b/pilot/src/lib/components/TaskCard.svelte @@ -4,11 +4,10 @@ interface Props { task: Task; - selected?: boolean; onClick: (task: Task) => void; } - let { task, selected = false, onClick }: Props = $props(); + let { task, onClick }: Props = $props(); let isRunning = $derived(task.status === 'processing' || task.status === 'queued'); @@ -27,33 +26,20 @@
onClick(task)} > -
-

{task.title}

+

{task.title}

{#if isRunning} {/if}
- -
+
{#if task.type} - {task.type} - {/if} -
- - -
- #{task.id} - {#if task.started_at && isRunning} - · Started {timeAgo(task.started_at)} - {:else if task.completed_at} - · {timeAgo(task.completed_at)} - {:else if task.created_at} - · {timeAgo(task.created_at)} + {task.type} {/if} + #{task.id}{#if task.started_at && isRunning} · {timeAgo(task.started_at)}{:else if task.completed_at} · {timeAgo(task.completed_at)}{:else if task.created_at} · {timeAgo(task.created_at)}{/if}
diff --git a/pilot/src/lib/stores/chat.svelte.ts b/pilot/src/lib/stores/chat.svelte.ts index 405c6095..2bdc6e78 100644 --- a/pilot/src/lib/stores/chat.svelte.ts +++ b/pilot/src/lib/stores/chat.svelte.ts @@ -36,8 +36,8 @@ export function selectChat(chat: Chat) { // Agent DO handles persistence — messages restored via cf_agent_chat_messages on WS connect } -export async function createNewChat(modelId?: string): Promise { - const chat = await chatsApi.create({ model_id: modelId }); +export async function createNewChat(projectId?: string, modelId?: string): Promise { + const chat = await chatsApi.create({ model_id: modelId, project_id: projectId }); chatState.chats = [chat, ...chatState.chats]; chatState.activeChat = chat; chatState.messages = []; @@ -45,6 +45,14 @@ export async function createNewChat(modelId?: string): Promise { return chat; } +export function getChatsByProject(projectId: string): Chat[] { + return chatState.chats.filter(c => c.project_id === projectId); +} + +export function getScratchChats(): Chat[] { + return chatState.chats.filter(c => !c.project_id); +} + export async function deleteChat(chatId: string) { await chatsApi.delete(chatId); chatState.chats = chatState.chats.filter(c => c.id !== chatId); diff --git a/pilot/src/lib/stores/nav.svelte.ts b/pilot/src/lib/stores/nav.svelte.ts index b5e959a8..2ed8ed34 100644 --- a/pilot/src/lib/stores/nav.svelte.ts +++ b/pilot/src/lib/stores/nav.svelte.ts @@ -22,6 +22,7 @@ function loadString(key: string, fallback: string): string { export const navState = $state({ view: 'dashboard' as NavView, + activeProjectId: loadString('ui:active-project', '') || null as string | null, sidebarCollapsed: loadBool('ui:sidebar-collapsed', false), sidebarMobileOpen: false, chatPanelOpen: loadBool('ui:chat-panel-open', true), @@ -40,6 +41,17 @@ export function navigate(view: NavView) { navState.sidebarMobileOpen = false; } +export function setActiveProject(projectId: string | null) { + navState.activeProjectId = projectId; + navState.view = 'dashboard'; + navState.sidebarMobileOpen = false; + if (projectId) { + persist('ui:active-project', projectId); + } else if (typeof localStorage !== 'undefined') { + localStorage.removeItem('ui:active-project'); + } +} + export function toggleSidebar() { navState.sidebarCollapsed = !navState.sidebarCollapsed; persist('ui:sidebar-collapsed', String(navState.sidebarCollapsed)); diff --git a/pilot/src/lib/stores/projects.svelte.ts b/pilot/src/lib/stores/projects.svelte.ts new file mode 100644 index 00000000..9d4e5dff --- /dev/null +++ b/pilot/src/lib/stores/projects.svelte.ts @@ -0,0 +1,55 @@ +import type { Project, CreateProjectRequest, UpdateProjectRequest } from '$lib/types'; +import { projects as projectsApi } from '$lib/api/client'; +import { navState } from './nav.svelte'; + +export const projectState = $state({ + projects: [] as Project[], + loading: false, + expandedProjects: new Set(), +}); + +export async function fetchProjects() { + projectState.loading = true; + try { + projectState.projects = await projectsApi.list(); + } catch (e) { + console.error('Failed to fetch projects:', e); + } finally { + projectState.loading = false; + } +} + +export async function createProject(data: CreateProjectRequest): Promise { + const project = await projectsApi.create(data); + projectState.projects = [project, ...projectState.projects]; + return project; +} + +export async function updateProject(id: string, data: UpdateProjectRequest): Promise { + const updated = await projectsApi.update(id, data); + projectState.projects = projectState.projects.map(p => p.id === id ? updated : p); + return updated; +} + +export async function deleteProject(id: string): Promise { + await projectsApi.delete(id); + projectState.projects = projectState.projects.filter(p => p.id !== id); + if (navState.activeProjectId === id) { + navState.activeProjectId = null; + } +} + +export function toggleProjectExpanded(id: string) { + const next = new Set(projectState.expandedProjects); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + projectState.expandedProjects = next; +} + +export function getActiveProject(): Project | null { + if (!navState.activeProjectId) return null; + return projectState.projects.find(p => p.id === navState.activeProjectId) ?? null; +} diff --git a/pilot/src/lib/stores/tasks.svelte.ts b/pilot/src/lib/stores/tasks.svelte.ts index 6dbc73e3..bbcccbf6 100644 --- a/pilot/src/lib/stores/tasks.svelte.ts +++ b/pilot/src/lib/stores/tasks.svelte.ts @@ -1,5 +1,6 @@ import type { Task, CreateTaskRequest, UpdateTaskRequest } from '$lib/types'; import { tasks as tasksApi } from '$lib/api/client'; +import { navState } from './nav.svelte'; export const taskState = $state<{ tasks: Task[]; loading: boolean }>({ tasks: [], @@ -11,13 +12,19 @@ function byUpdatedDesc(a: Task, b: Task) { return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); } +function filterByProject(tasks: Task[]): Task[] { + const pid = navState.activeProjectId; + if (!pid) return tasks; + return tasks.filter(t => t.project_id === pid); +} + export function getBacklogTasks(): Task[] { - return taskState.tasks.filter((t) => t.status === 'backlog').sort(byUpdatedDesc); + return filterByProject(taskState.tasks.filter((t) => t.status === 'backlog')).sort(byUpdatedDesc); } export function getInProgressTasks(): Task[] { - return taskState.tasks - .filter((t) => t.status === 'queued' || t.status === 'processing') + return filterByProject(taskState.tasks + .filter((t) => t.status === 'queued' || t.status === 'processing')) .sort((a, b) => { if (a.status === 'processing' && b.status !== 'processing') return -1; if (b.status === 'processing' && a.status !== 'processing') return 1; @@ -26,15 +33,15 @@ export function getInProgressTasks(): Task[] { } export function getBlockedTasks(): Task[] { - return taskState.tasks.filter((t) => t.status === 'blocked').sort(byUpdatedDesc); + return filterByProject(taskState.tasks.filter((t) => t.status === 'blocked')).sort(byUpdatedDesc); } export function getDoneTasks(): Task[] { - return taskState.tasks.filter((t) => t.status === 'done').sort(byUpdatedDesc); + return filterByProject(taskState.tasks.filter((t) => t.status === 'done')).sort(byUpdatedDesc); } export function getFailedTasks(): Task[] { - return taskState.tasks.filter((t) => t.status === 'failed').sort(byUpdatedDesc); + return filterByProject(taskState.tasks.filter((t) => t.status === 'failed')).sort(byUpdatedDesc); } export async function fetchTasks() { diff --git a/pilot/src/routes/+page.svelte b/pilot/src/routes/+page.svelte index 24ecfd73..6c6f0704 100644 --- a/pilot/src/routes/+page.svelte +++ b/pilot/src/routes/+page.svelte @@ -3,8 +3,9 @@ import { authState, fetchUser, logout } from '$lib/stores/auth.svelte'; import { fetchTasks } from '$lib/stores/tasks.svelte'; import { chatState, fetchChats, selectChat, loadRestoredMessages } from '$lib/stores/chat.svelte'; - import { navState, navigate, toggleMobileSidebar } from '$lib/stores/nav.svelte'; + import { navState, navigate, toggleMobileSidebar, setActiveProject } from '$lib/stores/nav.svelte'; import { handleAgentMessage, resetChatState, setOnMessagesRestored, setOnTasksUpdated } from '$lib/stores/agent.svelte'; + import { fetchProjects } from '$lib/stores/projects.svelte'; // Wire up callbacks (must be in +page.svelte to survive tree-shaking) setOnMessagesRestored(loadRestoredMessages); @@ -15,7 +16,6 @@ import SettingsPage from '$lib/components/SettingsPage.svelte'; import ApprovalsPage from '$lib/components/ApprovalsPage.svelte'; import IntegrationsPage from '$lib/components/IntegrationsPage.svelte'; - import ProjectsPage from '$lib/components/ProjectsPage.svelte'; import { Menu } from 'lucide-svelte'; let initialized = $state(false); @@ -92,6 +92,10 @@ if (currentChatId !== chatId) { currentChatId = chatId; selectChat(chat); + // Set active project based on chat's project + if (chat.project_id) { + setActiveProject(chat.project_id); + } if (authState.user) { connectAgentWebSocket(authState.user.id, chatId); } @@ -122,6 +126,7 @@ fetchUser().then(async () => { if (authState.user) { fetchTasks(); + fetchProjects(); await fetchChats(); // Route to chat if hash is present, otherwise just show dashboard handleRoute(); @@ -176,8 +181,6 @@ {:else if navState.view === 'integrations'} - {:else if navState.view === 'projects'} - {/if}
diff --git a/pilot/src/routes/api/projects/+server.ts b/pilot/src/routes/api/projects/+server.ts index 8aee3c67..a7544b9b 100644 --- a/pilot/src/routes/api/projects/+server.ts +++ b/pilot/src/routes/api/projects/+server.ts @@ -13,7 +13,7 @@ export const GET: RequestHandler = async ({ locals, platform }) => { export const POST: RequestHandler = async ({ locals, platform, request }) => { if (!locals.user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); - const data = (await request.json()) as { name?: string; instructions?: string; color?: string }; + const data = (await request.json()) as { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string }; const project = await createProject(platform.env.DB, locals.user.id, data); return json(project, { status: 201 }); }; diff --git a/pilot/src/routes/api/projects/[id]/+server.ts b/pilot/src/routes/api/projects/[id]/+server.ts index af4a9964..4904b296 100644 --- a/pilot/src/routes/api/projects/[id]/+server.ts +++ b/pilot/src/routes/api/projects/[id]/+server.ts @@ -15,7 +15,7 @@ export const GET: RequestHandler = async ({ params, locals, platform }) => { export const PUT: RequestHandler = async ({ params, request, locals, platform }) => { if (!locals.user || !platform?.env?.DB) throw error(401); - const data = await request.json() as { name?: string; instructions?: string; color?: string }; + const data = await request.json() as { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string }; const project = await updateProject(platform.env.DB, params.id, data); if (!project) throw error(404, 'Project not found'); return json(project); From 37d86a552b5269c4ecfc0d68655b2062d482cd1f Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 21 Feb 2026 08:34:51 -0600 Subject: [PATCH 16/16] fix(task-chat): fix streaming duplication and message persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming duplication: A race condition in the WebSocket close handler caused stale sockets to overwrite the current taskWs reference to null, triggering reconnects that created duplicate connections. Both connections fed text deltas into the same store, doubling every phrase. Fixed by checking `taskWs === ws` in all event handlers to ignore stale sockets, and clearing the reference BEFORE calling close() in disconnectTaskChat. Message persistence: Messages were cleared on every disconnect, so reopening the modal lost conversation history. Now messages are only cleared when switching to a DIFFERENT task — the server restores them via cf_agent_chat_messages on reconnect to the same task. Modal overflow: TaskChat containers used shrink-0 with fixed heights, pushing content past the modal bounds. Changed to flex-1 with min-h constraints so the chat participates in flex layout properly. Co-Authored-By: Claude Sonnet 4.6 --- pilot/src/lib/components/TaskDetail.svelte | 10 +++++----- pilot/src/lib/stores/taskChat.svelte.ts | 22 +++++++++++++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/pilot/src/lib/components/TaskDetail.svelte b/pilot/src/lib/components/TaskDetail.svelte index 98838526..e5c1166b 100644 --- a/pilot/src/lib/components/TaskDetail.svelte +++ b/pilot/src/lib/components/TaskDetail.svelte @@ -350,10 +350,10 @@ -
+
{#if hasFiles} -
+
@@ -430,7 +430,7 @@ {#if userId} -
+
{/if} @@ -515,7 +515,7 @@
{:else} -
+
{#if isEditing} @@ -600,7 +600,7 @@ {#if userId} -
+
{/if} diff --git a/pilot/src/lib/stores/taskChat.svelte.ts b/pilot/src/lib/stores/taskChat.svelte.ts index 7c4cdf73..af75efc5 100644 --- a/pilot/src/lib/stores/taskChat.svelte.ts +++ b/pilot/src/lib/stores/taskChat.svelte.ts @@ -43,9 +43,16 @@ export function connectTaskChat(userId: string, taskId: number) { return; } + // Clear messages if switching to a different task + const switchingTask = currentTaskId !== null && currentTaskId !== taskId; + // Clean up any existing connection disconnectTaskChat(); + if (switchingTask) { + taskChatState.messages = []; + } + currentUserId = userId; currentTaskId = taskId; taskChatState.taskId = taskId; @@ -56,13 +63,16 @@ export function connectTaskChat(userId: string, taskId: number) { const url = `${protocol}//${location.host}/agents/taskyou-agent/${agentName}`; const ws = new WebSocket(url); + taskWs = ws; ws.addEventListener('open', () => { + if (taskWs !== ws) return; // Stale socket taskChatState.connected = true; taskChatState.error = null; }); ws.addEventListener('message', (event) => { + if (taskWs !== ws) return; // Stale socket try { const data = JSON.parse(event.data); handleTaskChatMessage(data); @@ -72,6 +82,7 @@ export function connectTaskChat(userId: string, taskId: number) { }); ws.addEventListener('close', () => { + if (taskWs !== ws) return; // Stale socket — don't corrupt current state taskChatState.connected = false; taskWs = null; @@ -86,10 +97,9 @@ export function connectTaskChat(userId: string, taskId: number) { }); ws.addEventListener('error', () => { + if (taskWs !== ws) return; // Stale socket taskChatState.error = 'WebSocket connection error'; }); - - taskWs = ws; } /** @@ -105,17 +115,19 @@ export function disconnectTaskChat() { } if (taskWs) { - taskWs.close(); - taskWs = null; + const ws = taskWs; + taskWs = null; // Clear reference BEFORE closing so close handler ignores + ws.close(); } taskChatState.taskId = null; taskChatState.connected = false; - taskChatState.messages = []; taskChatState.streaming = false; taskChatState.streamingContent = ''; taskChatState.error = null; taskChatState.completedMessage = null; + // Note: messages are NOT cleared here — they persist until a new task connects + // or the server restores them via cf_agent_chat_messages on reconnect } /**