v2.0.0: BooCoder frontend — chat pane + diff pane + session picker
Phase 3 of v2.0. React + Vite SPA at apps/coder/web/ served by the coder Fastify server via @fastify/static with SPA fallback. Chat pane: message list via WS streaming (useSessionStream hook), input bar, POST /api/sessions/:id/messages on submit, markdown rendering via react-markdown + remark-gfm, inline tool-call display. Diff pane: fetches GET /api/sessions/:id/pending, shows pending changes with file path + operation badge (create/edit/delete), before/after diff for edits, Approve/Reject per change and Approve All/Reject All buttons. Layout: fixed two-pane split (chat 60%, diff 40%). Dark theme (bg-zinc-900). Desktop-first for v2.0.0. Session picker (Home page): lists projects and sessions from the shared DB. No CRUD — use BooChat's UI for that. Dockerfile updated: builds web app in builder stage, copies dist to runtime. index.ts registers fastifyStatic + SPA fallback route. Tailwind v4, React 18, TypeScript strict. ~20 new files, ~370KB built output. Functional developer tool UI, not polished consumer product — Phase 7 (v2.0.3) handles polish. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ WORKDIR /build
|
|||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||||
COPY apps/server/package.json ./apps/server/
|
COPY apps/server/package.json ./apps/server/
|
||||||
COPY apps/coder/package.json ./apps/coder/
|
COPY apps/coder/package.json ./apps/coder/
|
||||||
|
COPY apps/coder/web/package.json ./apps/coder/web/
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ COPY apps/server ./apps/server
|
|||||||
RUN pnpm -C apps/server build
|
RUN pnpm -C apps/server build
|
||||||
|
|
||||||
COPY apps/coder ./apps/coder
|
COPY apps/coder ./apps/coder
|
||||||
|
RUN pnpm -C apps/coder/web build
|
||||||
RUN pnpm -C apps/coder build
|
RUN pnpm -C apps/coder build
|
||||||
|
|
||||||
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
|
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
|
||||||
@@ -25,6 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git &&
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /out/coder ./
|
COPY --from=builder /out/coder ./
|
||||||
|
COPY --from=builder /build/apps/coder/web/dist ./web
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import fastifyWebsocket from '@fastify/websocket';
|
import fastifyWebsocket from '@fastify/websocket';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
import { loadConfig } from './config.js';
|
import { loadConfig } from './config.js';
|
||||||
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
||||||
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
|
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
|
||||||
@@ -111,6 +118,28 @@ async function main() {
|
|||||||
registerPendingRoutes(app, sql);
|
registerPendingRoutes(app, sql);
|
||||||
registerWebSocket(app, sql, broker);
|
registerWebSocket(app, sql, broker);
|
||||||
|
|
||||||
|
// Serve static frontend (built web app). In production, the dist/ is
|
||||||
|
// copied to ../web relative to the dist/ directory at /app/web. In dev,
|
||||||
|
// check adjacent to the source.
|
||||||
|
const webRoot = resolve(__dirname, '../web');
|
||||||
|
if (existsSync(webRoot)) {
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: webRoot,
|
||||||
|
prefix: '/',
|
||||||
|
// Don't intercept /api routes — static only serves files that exist.
|
||||||
|
wildcard: false,
|
||||||
|
});
|
||||||
|
// SPA fallback: serve index.html for non-API routes that don't match a file.
|
||||||
|
app.setNotFoundHandler(async (req, reply) => {
|
||||||
|
if (req.url.startsWith('/api')) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'not found' };
|
||||||
|
}
|
||||||
|
return reply.sendFile('index.html');
|
||||||
|
});
|
||||||
|
app.log.info(`serving frontend from ${webRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
app.log.info('shutting down');
|
app.log.info('shutting down');
|
||||||
|
|||||||
12
apps/coder/web/index.html
Normal file
12
apps/coder/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BooCoder</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-zinc-900 text-zinc-100">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
apps/coder/web/package.json
Normal file
29
apps/coder/web/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@boocode/coder-web",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc -b --noEmit",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vite": "^5.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/coder/web/postcss.config.js
Normal file
5
apps/coder/web/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
13
apps/coder/web/src/App.tsx
Normal file
13
apps/coder/web/src/App.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Home } from './pages/Home';
|
||||||
|
import { Session } from './pages/Session';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/sessions/:sessionId" element={<Session />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/coder/web/src/api/client.ts
Normal file
93
apps/coder/web/src/api/client.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Project, Session, Chat, Message, PendingChange } from './types';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public body: unknown,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
typeof body === 'object' && body && 'error' in body
|
||||||
|
? String((body as { error: unknown }).error)
|
||||||
|
: `HTTP ${status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : undefined;
|
||||||
|
if (!res.ok) throw new ApiError(res.status, data);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
health: () => request<{ ok: boolean; db: boolean; tools: number }>('/api/health'),
|
||||||
|
|
||||||
|
projects: {
|
||||||
|
list: (params?: { status?: 'open' | 'archived' }) =>
|
||||||
|
request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
|
||||||
|
},
|
||||||
|
|
||||||
|
sessions: {
|
||||||
|
listForProject: (projectId: string, status?: 'open' | 'archived') =>
|
||||||
|
request<Session[]>(
|
||||||
|
`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`,
|
||||||
|
),
|
||||||
|
get: (id: string) => request<Session>(`/api/sessions/${id}`),
|
||||||
|
},
|
||||||
|
|
||||||
|
chats: {
|
||||||
|
listForSession: (sessionId: string) =>
|
||||||
|
request<Chat[]>(`/api/sessions/${sessionId}/chats`),
|
||||||
|
create: (sessionId: string, body?: { name?: string }) =>
|
||||||
|
request<Chat>(`/api/sessions/${sessionId}/chats`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
messages: {
|
||||||
|
send: (sessionId: string, chatId: string, content: string) =>
|
||||||
|
request<{ user_message_id: string; assistant_message_id: string }>(
|
||||||
|
`/api/sessions/${sessionId}/messages`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content, chat_id: chatId }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
stop: (sessionId: string) =>
|
||||||
|
request<{ cancelled: boolean }>(`/api/sessions/${sessionId}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
pending: {
|
||||||
|
list: (sessionId: string) =>
|
||||||
|
request<PendingChange[]>(`/api/sessions/${sessionId}/pending`),
|
||||||
|
applyAll: (sessionId: string) =>
|
||||||
|
request<{ results: Array<{ id: string; success: boolean; error?: string }> }>(
|
||||||
|
`/api/sessions/${sessionId}/pending/apply`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
),
|
||||||
|
applyOne: (changeId: string) =>
|
||||||
|
request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/apply`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
rejectOne: (changeId: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/pending/${changeId}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
rewindOne: (changeId: string) =>
|
||||||
|
request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/rewind`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
89
apps/coder/web/src/api/types.ts
Normal file
89
apps/coder/web/src/api/types.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// Minimal types for the BooCoder frontend.
|
||||||
|
// Shared DB entities (same schema as BooChat).
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
status: 'open' | 'archived';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
name: string | null;
|
||||||
|
model: string | null;
|
||||||
|
status: 'open' | 'archived';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chat {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
name: string | null;
|
||||||
|
status: 'open' | 'archived';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResult {
|
||||||
|
tool_call_id: string;
|
||||||
|
output: string;
|
||||||
|
truncated?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
chat_id: string;
|
||||||
|
role: 'user' | 'assistant' | 'tool' | 'system';
|
||||||
|
content: string;
|
||||||
|
kind: string;
|
||||||
|
tool_calls: ToolCall[] | null;
|
||||||
|
tool_results: ToolResult | null;
|
||||||
|
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||||
|
tokens_used: number | null;
|
||||||
|
ctx_used: number | null;
|
||||||
|
ctx_max: number | null;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
metadata: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingChange {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
task_id: string | null;
|
||||||
|
file_path: string;
|
||||||
|
operation: 'create' | 'edit' | 'delete';
|
||||||
|
old_string: string | null;
|
||||||
|
new_string: string | null;
|
||||||
|
content: string | null;
|
||||||
|
diff: string | null;
|
||||||
|
status: 'pending' | 'applied' | 'rejected' | 'reverted';
|
||||||
|
created_at: string;
|
||||||
|
applied_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket frame types (subset of what the coder backend publishes)
|
||||||
|
export type WsFrame =
|
||||||
|
| { type: 'snapshot'; messages: Message[] }
|
||||||
|
| { type: 'message_started'; message_id: string; chat_id: string; role: Message['role'] }
|
||||||
|
| { type: 'delta'; message_id: string; chat_id: string; content: string }
|
||||||
|
| { type: 'tool_call'; message_id: string; chat_id: string; tool_call: ToolCall }
|
||||||
|
| { type: 'tool_result'; tool_message_id: string; chat_id: string; tool_call_id: string; output: string; truncated?: boolean; error?: boolean }
|
||||||
|
| { type: 'message_complete'; message_id: string; chat_id: string; tokens_used?: number; ctx_used?: number; ctx_max?: number; started_at?: string; finished_at?: string; metadata?: unknown }
|
||||||
|
| { type: 'error'; message_id?: string; error: string; reason?: string }
|
||||||
|
| { type: 'pending_change_added'; change: PendingChange }
|
||||||
|
| { type: 'pending_change_updated'; change: PendingChange };
|
||||||
131
apps/coder/web/src/components/ChatPane.tsx
Normal file
131
apps/coder/web/src/components/ChatPane.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Send, Square } from 'lucide-react';
|
||||||
|
import type { Message } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { MessageBubble } from './MessageBubble';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionId: string;
|
||||||
|
chatId: string;
|
||||||
|
messages: Message[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }: Props) {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when messages change
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
useEffect(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||||
|
}, [input]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const content = input.trim();
|
||||||
|
if (!content || sending || isStreaming) return;
|
||||||
|
|
||||||
|
setInput('');
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await api.messages.send(sessionId, chatId, content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('send failed:', err);
|
||||||
|
// Restore input on failure
|
||||||
|
setInput(content);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
try {
|
||||||
|
await api.messages.stop(sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('stop failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter out system messages for display (sentinels)
|
||||||
|
const visibleMessages = messages.filter((m) => m.role !== 'system');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Connection indicator */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800 text-xs text-zinc-500">
|
||||||
|
<div
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
|
||||||
|
/>
|
||||||
|
<span>{connected ? 'Connected' : 'Disconnected'}</span>
|
||||||
|
{isStreaming && (
|
||||||
|
<span className="text-blue-400 ml-auto">Generating...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages list */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
{visibleMessages.length === 0 && (
|
||||||
|
<div className="text-center text-zinc-500 mt-8">
|
||||||
|
<p className="text-lg font-medium">BooCoder</p>
|
||||||
|
<p className="text-sm mt-1">Send a message to start coding.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleMessages.map((msg) => (
|
||||||
|
<MessageBubble key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<div className="border-t border-zinc-800 px-4 py-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Message BooCoder..."
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
{isStreaming ? (
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
className="p-2 rounded-lg bg-red-600 hover:bg-red-500 text-white transition-colors"
|
||||||
|
title="Stop generation"
|
||||||
|
>
|
||||||
|
<Square size={18} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim() || sending}
|
||||||
|
className="p-2 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white transition-colors"
|
||||||
|
title="Send message"
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
apps/coder/web/src/components/DiffPane.tsx
Normal file
352
apps/coder/web/src/components/DiffPane.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Check, X, RotateCcw, FileText, FilePlus, Trash2, RefreshCw } from 'lucide-react';
|
||||||
|
import type { PendingChange } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionId: string;
|
||||||
|
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffPane({ sessionId, onPendingChange }: Props) {
|
||||||
|
const [changes, setChanges] = useState<PendingChange[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchPending = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.pending.list(sessionId);
|
||||||
|
setChanges(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('fetch pending failed:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPending();
|
||||||
|
}, [fetchPending]);
|
||||||
|
|
||||||
|
// Listen for WS pending change events
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = onPendingChange((change) => {
|
||||||
|
setChanges((prev) => {
|
||||||
|
const idx = prev.findIndex((c) => c.id === change.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = change;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [...prev, change];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [onPendingChange]);
|
||||||
|
|
||||||
|
const pendingChanges = changes.filter((c) => c.status === 'pending');
|
||||||
|
const resolvedChanges = changes.filter((c) => c.status !== 'pending');
|
||||||
|
|
||||||
|
const handleApplyOne = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.pending.applyOne(id);
|
||||||
|
setChanges((prev) =>
|
||||||
|
prev.map((c) => (c.id === id ? { ...c, status: 'applied' as const } : c)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('apply failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectOne = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.pending.rejectOne(id);
|
||||||
|
setChanges((prev) =>
|
||||||
|
prev.map((c) => (c.id === id ? { ...c, status: 'rejected' as const } : c)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('reject failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRewindOne = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.pending.rewindOne(id);
|
||||||
|
setChanges((prev) =>
|
||||||
|
prev.map((c) => (c.id === id ? { ...c, status: 'reverted' as const } : c)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('rewind failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyAll = async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.pending.applyAll(sessionId);
|
||||||
|
const appliedIds = new Set(
|
||||||
|
result.results.filter((r) => r.success).map((r) => r.id),
|
||||||
|
);
|
||||||
|
setChanges((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
appliedIds.has(c.id) ? { ...c, status: 'applied' as const } : c,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('apply all failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectAll = async () => {
|
||||||
|
// Reject each pending change individually (no batch reject endpoint)
|
||||||
|
for (const c of pendingChanges) {
|
||||||
|
await handleRejectOne(c.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpIcon = ({ op }: { op: PendingChange['operation'] }) => {
|
||||||
|
switch (op) {
|
||||||
|
case 'create':
|
||||||
|
return <FilePlus size={14} className="text-green-400" />;
|
||||||
|
case 'edit':
|
||||||
|
return <FileText size={14} className="text-blue-400" />;
|
||||||
|
case 'delete':
|
||||||
|
return <Trash2 size={14} className="text-red-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusBadge = ({ status }: { status: PendingChange['status'] }) => {
|
||||||
|
const colors: Record<PendingChange['status'], string> = {
|
||||||
|
pending: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
applied: 'bg-green-500/20 text-green-400',
|
||||||
|
rejected: 'bg-zinc-500/20 text-zinc-400',
|
||||||
|
reverted: 'bg-orange-500/20 text-orange-400',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colors[status]}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
|
||||||
|
<h2 className="text-sm font-medium text-zinc-300">
|
||||||
|
Pending Changes
|
||||||
|
{pendingChanges.length > 0 && (
|
||||||
|
<span className="ml-1.5 text-xs text-zinc-500">
|
||||||
|
({pendingChanges.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={fetchPending}
|
||||||
|
className="p-1 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
{pendingChanges.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleApplyAll}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white"
|
||||||
|
>
|
||||||
|
Apply All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRejectAll}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300"
|
||||||
|
>
|
||||||
|
Reject All
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Changes list */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center text-zinc-500 text-sm py-8">Loading...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && changes.length === 0 && (
|
||||||
|
<div className="text-center text-zinc-500 text-sm py-8">
|
||||||
|
No pending changes yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending changes first */}
|
||||||
|
{pendingChanges.map((change) => (
|
||||||
|
<ChangeItem
|
||||||
|
key={change.id}
|
||||||
|
change={change}
|
||||||
|
expanded={expandedId === change.id}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedId((prev) => (prev === change.id ? null : change.id))
|
||||||
|
}
|
||||||
|
onApply={() => handleApplyOne(change.id)}
|
||||||
|
onReject={() => handleRejectOne(change.id)}
|
||||||
|
OpIcon={OpIcon}
|
||||||
|
StatusBadge={StatusBadge}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Resolved changes */}
|
||||||
|
{resolvedChanges.length > 0 && pendingChanges.length > 0 && (
|
||||||
|
<div className="border-t border-zinc-800 my-1" />
|
||||||
|
)}
|
||||||
|
{resolvedChanges.map((change) => (
|
||||||
|
<ChangeItem
|
||||||
|
key={change.id}
|
||||||
|
change={change}
|
||||||
|
expanded={expandedId === change.id}
|
||||||
|
onToggle={() =>
|
||||||
|
setExpandedId((prev) => (prev === change.id ? null : change.id))
|
||||||
|
}
|
||||||
|
onRewind={
|
||||||
|
change.status === 'applied'
|
||||||
|
? () => handleRewindOne(change.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
OpIcon={OpIcon}
|
||||||
|
StatusBadge={StatusBadge}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangeItemProps {
|
||||||
|
change: PendingChange;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onApply?: () => void;
|
||||||
|
onReject?: () => void;
|
||||||
|
onRewind?: () => void;
|
||||||
|
OpIcon: React.ComponentType<{ op: PendingChange['operation'] }>;
|
||||||
|
StatusBadge: React.ComponentType<{ status: PendingChange['status'] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeItem({
|
||||||
|
change,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
onApply,
|
||||||
|
onReject,
|
||||||
|
onRewind,
|
||||||
|
OpIcon,
|
||||||
|
StatusBadge,
|
||||||
|
}: ChangeItemProps) {
|
||||||
|
const fileName = change.file_path.split('/').pop() || change.file_path;
|
||||||
|
const dirPath = change.file_path.split('/').slice(0, -1).join('/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-zinc-800/50">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-2 hover:bg-zinc-800/50 cursor-pointer"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<OpIcon op={change.operation} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-mono text-zinc-200 truncate block">
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
{dirPath && (
|
||||||
|
<span className="text-[11px] text-zinc-500 truncate block">
|
||||||
|
{dirPath}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={change.status} />
|
||||||
|
{change.status === 'pending' && (
|
||||||
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onApply?.();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-green-600/30 text-green-400"
|
||||||
|
title="Apply"
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onReject?.();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-red-600/30 text-red-400"
|
||||||
|
title="Reject"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{change.status === 'applied' && onRewind && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRewind();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-orange-600/30 text-orange-400"
|
||||||
|
title="Rewind"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
{change.operation === 'edit' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{change.old_string && (
|
||||||
|
<div className="rounded bg-red-950/30 border border-red-900/30 p-2">
|
||||||
|
<div className="text-[10px] text-red-400 mb-1 font-medium">
|
||||||
|
Remove
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs text-red-200 whitespace-pre-wrap break-all font-mono">
|
||||||
|
{change.old_string}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{change.new_string && (
|
||||||
|
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
|
||||||
|
<div className="text-[10px] text-green-400 mb-1 font-medium">
|
||||||
|
Add
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono">
|
||||||
|
{change.new_string}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{change.operation === 'create' && change.content && (
|
||||||
|
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
|
||||||
|
<div className="text-[10px] text-green-400 mb-1 font-medium">
|
||||||
|
New file
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono max-h-60 overflow-y-auto">
|
||||||
|
{change.content.length > 2000
|
||||||
|
? change.content.slice(0, 2000) + '\n... (truncated)'
|
||||||
|
: change.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{change.operation === 'delete' && (
|
||||||
|
<div className="rounded bg-red-950/30 border border-red-900/30 p-2 text-xs text-red-300">
|
||||||
|
This file will be deleted.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
apps/coder/web/src/components/Layout.tsx
Normal file
62
apps/coder/web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Code2, MessageSquare, GitPullRequest } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatPane: React.ReactNode;
|
||||||
|
diffPane: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ chatPane, diffPane }: Props) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'chat' | 'diff'>('chat');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-zinc-900">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-900/95">
|
||||||
|
<Code2 size={20} className="text-blue-400" />
|
||||||
|
<h1 className="text-sm font-semibold text-zinc-200">BooCoder</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile tab bar (visible below lg breakpoint) */}
|
||||||
|
<div className="lg:hidden flex border-b border-zinc-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('chat')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
|
||||||
|
activeTab === 'chat'
|
||||||
|
? 'text-blue-400 border-b-2 border-blue-400'
|
||||||
|
: 'text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MessageSquare size={14} />
|
||||||
|
Chat
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('diff')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
|
||||||
|
activeTab === 'diff'
|
||||||
|
? 'text-blue-400 border-b-2 border-blue-400'
|
||||||
|
: 'text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitPullRequest size={14} />
|
||||||
|
Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop split layout */}
|
||||||
|
<div className="flex-1 hidden lg:flex overflow-hidden">
|
||||||
|
<div className="w-[60%] border-r border-zinc-800 overflow-hidden">
|
||||||
|
{chatPane}
|
||||||
|
</div>
|
||||||
|
<div className="w-[40%] overflow-hidden">
|
||||||
|
{diffPane}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: show only the active tab */}
|
||||||
|
<div className="flex-1 lg:hidden overflow-hidden">
|
||||||
|
{activeTab === 'chat' ? chatPane : diffPane}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
apps/coder/web/src/components/MessageBubble.tsx
Normal file
115
apps/coder/web/src/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import Markdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import type { Message } from '@/api/types';
|
||||||
|
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBubble({ message }: Props) {
|
||||||
|
if (message.role === 'tool') {
|
||||||
|
return <ToolResultBubble message={message} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
const isStreaming = message.status === 'streaming';
|
||||||
|
const isFailed = message.status === 'failed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-lg px-4 py-2.5 ${
|
||||||
|
isUser
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-zinc-800 text-zinc-100 border border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isFailed && (
|
||||||
|
<div className="flex items-center gap-1.5 text-red-400 text-xs mb-1">
|
||||||
|
<AlertCircle size={12} />
|
||||||
|
<span>Failed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.tool_calls && message.tool_calls.length > 0 && (
|
||||||
|
<div className="mb-2 space-y-1">
|
||||||
|
{message.tool_calls.map((tc) => (
|
||||||
|
<div
|
||||||
|
key={tc.id}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
<Wrench size={11} />
|
||||||
|
<span className="font-mono">{tc.name}</span>
|
||||||
|
<span className="text-zinc-500 truncate max-w-[200px]">
|
||||||
|
{truncateArgs(tc.arguments)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.content.trim() && (
|
||||||
|
<div className="prose prose-invert prose-sm max-w-none [&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_pre]:overflow-x-auto [&_code]:text-zinc-300 [&_p]:my-1.5">
|
||||||
|
<Markdown remarkPlugins={[remarkGfm]}>{message.content}</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStreaming && !message.content.trim() && (
|
||||||
|
<div className="flex items-center gap-1.5 text-zinc-400">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
<span className="text-xs">Thinking...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStreaming && message.content.trim() && (
|
||||||
|
<span className="inline-block w-1.5 h-4 bg-zinc-400 animate-pulse ml-0.5 align-middle" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolResultBubble({ message }: Props) {
|
||||||
|
const result = message.tool_results;
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const isError = result.error;
|
||||||
|
const output = result.output || '';
|
||||||
|
const displayOutput =
|
||||||
|
output.length > 300 ? output.slice(0, 300) + '...' : output;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-start mb-2 ml-6">
|
||||||
|
<div
|
||||||
|
className={`max-w-[80%] rounded px-3 py-2 text-xs font-mono border ${
|
||||||
|
isError
|
||||||
|
? 'bg-red-950/30 border-red-800/50 text-red-300'
|
||||||
|
: 'bg-zinc-800/50 border-zinc-700/50 text-zinc-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result.truncated && (
|
||||||
|
<span className="text-yellow-500 text-[10px] block mb-1">
|
||||||
|
[truncated]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<pre className="whitespace-pre-wrap break-all">{displayOutput}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateArgs(args: string): string {
|
||||||
|
if (!args) return '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(args);
|
||||||
|
const keys = Object.keys(parsed);
|
||||||
|
if (keys.length === 0) return '';
|
||||||
|
const first = keys[0]!;
|
||||||
|
const val = String(parsed[first]);
|
||||||
|
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
|
||||||
|
return `${first}: ${display}`;
|
||||||
|
} catch {
|
||||||
|
return args.length > 50 ? args.slice(0, 50) + '...' : args;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/coder/web/src/globals.css
Normal file
22
apps/coder/web/src/globals.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for dark theme */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #3f3f46;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #52525b;
|
||||||
|
}
|
||||||
230
apps/coder/web/src/hooks/useSessionStream.ts
Normal file
230
apps/coder/web/src/hooks/useSessionStream.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import type { Message, WsFrame, PendingChange } from '@/api/types';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
messages: Message[];
|
||||||
|
connected: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFrame(state: State, frame: WsFrame): State {
|
||||||
|
switch (frame.type) {
|
||||||
|
case 'snapshot': {
|
||||||
|
return { ...state, messages: frame.messages };
|
||||||
|
}
|
||||||
|
case 'message_started': {
|
||||||
|
const exists = state.messages.some((m) => m.id === frame.message_id);
|
||||||
|
if (exists) return state;
|
||||||
|
const newMsg: Message = {
|
||||||
|
id: frame.message_id,
|
||||||
|
session_id: '',
|
||||||
|
chat_id: frame.chat_id,
|
||||||
|
role: frame.role,
|
||||||
|
content: '',
|
||||||
|
kind: 'message',
|
||||||
|
tool_calls: null,
|
||||||
|
tool_results: null,
|
||||||
|
status: frame.role === 'system' ? 'complete' : 'streaming',
|
||||||
|
tokens_used: null,
|
||||||
|
ctx_used: null,
|
||||||
|
ctx_max: null,
|
||||||
|
started_at: null,
|
||||||
|
finished_at: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
metadata: null,
|
||||||
|
};
|
||||||
|
return { ...state, messages: [...state.messages, newMsg] };
|
||||||
|
}
|
||||||
|
case 'delta': {
|
||||||
|
const next = state.messages.map((m) =>
|
||||||
|
m.id === frame.message_id ? { ...m, content: m.content + frame.content } : m,
|
||||||
|
);
|
||||||
|
return { ...state, messages: next };
|
||||||
|
}
|
||||||
|
case 'tool_call': {
|
||||||
|
const next = state.messages.map((m) =>
|
||||||
|
m.id === frame.message_id
|
||||||
|
? { ...m, tool_calls: [...(m.tool_calls ?? []), frame.tool_call] }
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
return { ...state, messages: next };
|
||||||
|
}
|
||||||
|
case 'tool_result': {
|
||||||
|
const exists = state.messages.some((m) => m.id === frame.tool_message_id);
|
||||||
|
if (exists) {
|
||||||
|
const next = state.messages.map((m) =>
|
||||||
|
m.id === frame.tool_message_id
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
role: 'tool' as const,
|
||||||
|
tool_results: {
|
||||||
|
tool_call_id: frame.tool_call_id,
|
||||||
|
output: frame.output,
|
||||||
|
truncated: frame.truncated,
|
||||||
|
...(frame.error ? { error: frame.error } : {}),
|
||||||
|
},
|
||||||
|
status: 'complete' as const,
|
||||||
|
}
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
return { ...state, messages: next };
|
||||||
|
}
|
||||||
|
const newMsg: Message = {
|
||||||
|
id: frame.tool_message_id,
|
||||||
|
session_id: '',
|
||||||
|
chat_id: frame.chat_id,
|
||||||
|
role: 'tool',
|
||||||
|
content: '',
|
||||||
|
kind: 'message',
|
||||||
|
tool_calls: null,
|
||||||
|
tool_results: {
|
||||||
|
tool_call_id: frame.tool_call_id,
|
||||||
|
output: frame.output,
|
||||||
|
truncated: frame.truncated,
|
||||||
|
...(frame.error ? { error: frame.error } : {}),
|
||||||
|
},
|
||||||
|
status: 'complete',
|
||||||
|
tokens_used: null,
|
||||||
|
ctx_used: null,
|
||||||
|
ctx_max: null,
|
||||||
|
started_at: null,
|
||||||
|
finished_at: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
metadata: null,
|
||||||
|
};
|
||||||
|
return { ...state, messages: [...state.messages, newMsg] };
|
||||||
|
}
|
||||||
|
case 'message_complete': {
|
||||||
|
const next = state.messages.map((m) =>
|
||||||
|
m.id === frame.message_id
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
status: 'complete' as const,
|
||||||
|
...(frame.tokens_used !== undefined ? { tokens_used: frame.tokens_used } : {}),
|
||||||
|
...(frame.ctx_used !== undefined ? { ctx_used: frame.ctx_used } : {}),
|
||||||
|
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
|
||||||
|
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
|
||||||
|
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
|
||||||
|
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
|
||||||
|
}
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
return { ...state, messages: next };
|
||||||
|
}
|
||||||
|
case 'error': {
|
||||||
|
const next = frame.message_id
|
||||||
|
? state.messages.map((m) =>
|
||||||
|
m.id === frame.message_id ? { ...m, status: 'failed' as const } : m,
|
||||||
|
)
|
||||||
|
: state.messages;
|
||||||
|
return { ...state, messages: next, error: frame.error };
|
||||||
|
}
|
||||||
|
case 'pending_change_added':
|
||||||
|
case 'pending_change_updated':
|
||||||
|
// These are handled by the pending changes listener, not the message state
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECONNECT_INITIAL_MS = 1000;
|
||||||
|
const RECONNECT_MAX_MS = 30_000;
|
||||||
|
|
||||||
|
interface SessionStreamResult {
|
||||||
|
messages: Message[];
|
||||||
|
connected: boolean;
|
||||||
|
error: string | null;
|
||||||
|
isStreaming: boolean;
|
||||||
|
/** Listeners for pending change frames */
|
||||||
|
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionStream(sessionId: string | undefined): SessionStreamResult {
|
||||||
|
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const pendingListenersRef = useRef<Set<(change: PendingChange) => void>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
setState({ messages: [], connected: false, error: null });
|
||||||
|
|
||||||
|
let unmounted = false;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let reconnectDelay = RECONNECT_INITIAL_MS;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (unmounted) return;
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectDelay = RECONNECT_INITIAL_MS;
|
||||||
|
setState((s) => ({ ...s, connected: true, error: null }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let frame: WsFrame;
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify pending change listeners
|
||||||
|
if (frame.type === 'pending_change_added' || frame.type === 'pending_change_updated') {
|
||||||
|
for (const cb of pendingListenersRef.current) {
|
||||||
|
cb(frame.change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((s) => applyFrame(s, frame));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (unmounted) return;
|
||||||
|
setState((s) => ({ ...s, connected: false }));
|
||||||
|
const delay = reconnectDelay;
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||||
|
reconnectTimer = setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unmounted = true;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
const ws = wsRef.current;
|
||||||
|
wsRef.current = null;
|
||||||
|
if (ws)
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const isStreaming = state.messages.some((m) => m.status === 'streaming');
|
||||||
|
|
||||||
|
const onPendingChange = useCallback((cb: (change: PendingChange) => void) => {
|
||||||
|
pendingListenersRef.current.add(cb);
|
||||||
|
return () => {
|
||||||
|
pendingListenersRef.current.delete(cb);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: state.messages,
|
||||||
|
connected: state.connected,
|
||||||
|
error: state.error,
|
||||||
|
isStreaming,
|
||||||
|
onPendingChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
13
apps/coder/web/src/main.tsx
Normal file
13
apps/coder/web/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { App } from './App';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
138
apps/coder/web/src/pages/Home.tsx
Normal file
138
apps/coder/web/src/pages/Home.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Code2, Folder, ArrowRight } from 'lucide-react';
|
||||||
|
import type { Project, Session } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
|
||||||
|
export function Home() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Fetch projects on mount
|
||||||
|
useEffect(() => {
|
||||||
|
api.projects
|
||||||
|
.list({ status: 'open' })
|
||||||
|
.then(setProjects)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch sessions when a project is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProject) {
|
||||||
|
setSessions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.sessions
|
||||||
|
.listForProject(selectedProject, 'open')
|
||||||
|
.then(setSessions)
|
||||||
|
.catch(console.error);
|
||||||
|
}, [selectedProject]);
|
||||||
|
|
||||||
|
const handleSessionClick = (session: Session) => {
|
||||||
|
navigate(`/sessions/${session.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||||
|
<div className="text-zinc-500">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-900 p-6">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-8">
|
||||||
|
<Code2 size={28} className="text-blue-400" />
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-100">BooCoder</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects list */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-3">
|
||||||
|
Projects
|
||||||
|
</h2>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm">
|
||||||
|
No projects found. Create one in BooChat first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => setSelectedProject(project.id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${
|
||||||
|
selectedProject === project.id
|
||||||
|
? 'bg-blue-600/20 border border-blue-500/40'
|
||||||
|
: 'bg-zinc-800/50 border border-zinc-800 hover:bg-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Folder
|
||||||
|
size={16}
|
||||||
|
className={
|
||||||
|
selectedProject === project.id
|
||||||
|
? 'text-blue-400'
|
||||||
|
: 'text-zinc-500'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-zinc-200 truncate">
|
||||||
|
{project.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 truncate">
|
||||||
|
{project.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions list */}
|
||||||
|
{selectedProject && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-3">
|
||||||
|
Sessions
|
||||||
|
</h2>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm">
|
||||||
|
No open sessions. Create one in BooChat first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<button
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => handleSessionClick(session)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-zinc-800/50 border border-zinc-800 hover:bg-zinc-800 text-left transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-zinc-200 truncate">
|
||||||
|
{session.name || 'Untitled session'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{new Date(session.updated_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight
|
||||||
|
size={16}
|
||||||
|
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/coder/web/src/pages/Session.tsx
Normal file
86
apps/coder/web/src/pages/Session.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import type { Chat } from '@/api/types';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||||
|
import { ChatPane } from '@/components/ChatPane';
|
||||||
|
import { DiffPane } from '@/components/DiffPane';
|
||||||
|
import { Layout } from '@/components/Layout';
|
||||||
|
|
||||||
|
export function Session() {
|
||||||
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [chat, setChat] = useState<Chat | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const { messages, connected, isStreaming, onPendingChange } =
|
||||||
|
useSessionStream(sessionId);
|
||||||
|
|
||||||
|
// Get or create a chat for this session
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
api.chats
|
||||||
|
.listForSession(sessionId)
|
||||||
|
.then((chats) => {
|
||||||
|
// Use the first open chat, or create one
|
||||||
|
const openChat = chats.find((c) => c.status === 'open');
|
||||||
|
if (openChat) {
|
||||||
|
setChat(openChat);
|
||||||
|
} else {
|
||||||
|
// Create a new chat
|
||||||
|
return api.chats.create(sessionId).then((newChat) => {
|
||||||
|
setChat(newChat);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
navigate('/');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||||
|
<div className="text-zinc-500">Loading session...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-900 flex flex-col items-center justify-center gap-4">
|
||||||
|
<div className="text-zinc-500">Could not load chat for this session.</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
Back to projects
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
chatPane={
|
||||||
|
<ChatPane
|
||||||
|
sessionId={sessionId}
|
||||||
|
chatId={chat.id}
|
||||||
|
messages={messages}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
connected={connected}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
diffPane={
|
||||||
|
<DiffPane sessionId={sessionId} onPendingChange={onPendingChange} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/coder/web/src/vite-env.d.ts
vendored
Normal file
1
apps/coder/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
27
apps/coder/web/tsconfig.app.json
Normal file
27
apps/coder/web/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
apps/coder/web/tsconfig.json
Normal file
13
apps/coder/web/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/coder/web/tsconfig.node.json
Normal file
14
apps/coder/web/tsconfig.node.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
2
apps/coder/web/vite.config.d.ts
vendored
Normal file
2
apps/coder/web/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
25
apps/coder/web/vite.config.js
Normal file
25
apps/coder/web/vite.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
26
apps/coder/web/vite.config.ts
Normal file
26
apps/coder/web/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@@ -80,6 +80,49 @@ importers:
|
|||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))
|
||||||
|
|
||||||
|
apps/coder/web:
|
||||||
|
dependencies:
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^1.16.0
|
||||||
|
version: 1.16.0(react@18.3.1)
|
||||||
|
react:
|
||||||
|
specifier: ^18.3.1
|
||||||
|
version: 18.3.1
|
||||||
|
react-dom:
|
||||||
|
specifier: ^18.3.1
|
||||||
|
version: 18.3.1(react@18.3.1)
|
||||||
|
react-markdown:
|
||||||
|
specifier: ^10.1.0
|
||||||
|
version: 10.1.0(@types/react@18.3.28)(react@18.3.1)
|
||||||
|
react-router-dom:
|
||||||
|
specifier: ^6.26.0
|
||||||
|
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
remark-gfm:
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.1
|
||||||
|
devDependencies:
|
||||||
|
'@tailwindcss/postcss':
|
||||||
|
specifier: ^4.3.0
|
||||||
|
version: 4.3.0
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^18.3.3
|
||||||
|
version: 18.3.28
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.7(@types/react@18.3.28)
|
||||||
|
'@vitejs/plugin-react':
|
||||||
|
specifier: ^4.3.1
|
||||||
|
version: 4.7.0(vite@5.4.21(@types/node@20.19.41)(lightningcss@1.32.0))
|
||||||
|
tailwindcss:
|
||||||
|
specifier: ^4.3.0
|
||||||
|
version: 4.3.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.5.0
|
||||||
|
version: 5.9.3
|
||||||
|
vite:
|
||||||
|
specifier: ^5.3.4
|
||||||
|
version: 5.4.21(@types/node@20.19.41)(lightningcss@1.32.0)
|
||||||
|
|
||||||
apps/server:
|
apps/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/openai-compatible':
|
'@ai-sdk/openai-compatible':
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
packages:
|
packages:
|
||||||
- "apps/*"
|
- "apps/*"
|
||||||
|
- "apps/coder/web"
|
||||||
|
|||||||
Reference in New Issue
Block a user