Put in a recent project picker

This commit is contained in:
Dave
2026-02-16 18:57:39 +00:00
parent 539cbba409
commit ffab287d16
9 changed files with 1334 additions and 1116 deletions

View File

@@ -4,90 +4,128 @@ import { Chat } from "./components/Chat";
import "./App.css";
function App() {
const [projectPath, setProjectPath] = React.useState<string | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [pathInput, setPathInput] = React.useState("");
const [isOpening, setIsOpening] = React.useState(false);
const [projectPath, setProjectPath] = React.useState<string | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [pathInput, setPathInput] = React.useState("");
const [isOpening, setIsOpening] = React.useState(false);
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
async function openProject(path: string) {
if (!path.trim()) {
setErrorMsg("Please enter a project path.");
return;
}
React.useEffect(() => {
api
.getKnownProjects()
.then((projects) => setKnownProjects(projects))
.catch((error) => console.error(error));
}, []);
try {
setErrorMsg(null);
setIsOpening(true);
const confirmedPath = await api.openProject(path.trim());
setProjectPath(confirmedPath);
} catch (e) {
console.error(e);
const message =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "An error occurred opening the project.";
setErrorMsg(message);
} finally {
setIsOpening(false);
}
}
async function openProject(path: string) {
if (!path.trim()) {
setErrorMsg("Please enter a project path.");
return;
}
function handleOpen() {
void openProject(pathInput);
}
try {
setErrorMsg(null);
setIsOpening(true);
const confirmedPath = await api.openProject(path.trim());
setProjectPath(confirmedPath);
} catch (e) {
console.error(e);
const message =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "An error occurred opening the project.";
setErrorMsg(message);
} finally {
setIsOpening(false);
}
}
async function closeProject() {
try {
await api.closeProject();
setProjectPath(null);
} catch (e) {
console.error(e);
}
}
function handleOpen() {
void openProject(pathInput);
}
return (
<main
className="container"
style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
>
{!projectPath ? (
<div
className="selection-screen"
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
>
<h1>AI Code Assistant</h1>
<p>Paste a project path to start the Story-Driven Spec Workflow.</p>
<input
type="text"
value={pathInput}
placeholder="/path/to/project"
onChange={(event) => setPathInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleOpen();
}
}}
style={{ width: "100%", padding: "10px", marginTop: "12px" }}
/>
<button type="button" onClick={handleOpen} disabled={isOpening}>
{isOpening ? "Opening..." : "Open Project"}
</button>
</div>
) : (
<div className="workspace" style={{ height: "100%" }}>
<Chat projectPath={projectPath} onCloseProject={closeProject} />
</div>
)}
async function closeProject() {
try {
await api.closeProject();
setProjectPath(null);
} catch (e) {
console.error(e);
}
}
{errorMsg && (
<div className="error-message" style={{ marginTop: "20px" }}>
<p style={{ color: "red" }}>Error: {errorMsg}</p>
</div>
)}
</main>
);
return (
<main
className="container"
style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
>
{!projectPath ? (
<div
className="selection-screen"
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
>
<h1>AI Code Assistant</h1>
<p>Paste a project path to start the Story-Driven Spec Workflow.</p>
{knownProjects.length > 0 && (
<div style={{ marginTop: "12px" }}>
<div style={{ fontSize: "0.9em", color: "#666" }}>
Recent projects
</div>
<ul style={{ listStyle: "none", padding: 0, margin: "8px 0 0" }}>
{knownProjects.map((project) => (
<li key={project} style={{ marginBottom: "6px" }}>
<button
type="button"
onClick={() => void openProject(project)}
style={{
width: "100%",
textAlign: "left",
padding: "8px 10px",
borderRadius: "6px",
border: "1px solid #ddd",
background: "#f7f7f7",
cursor: "pointer",
fontFamily: "monospace",
fontSize: "0.9em",
}}
>
{project}
</button>
</li>
))}
</ul>
</div>
)}
<input
type="text"
value={pathInput}
placeholder="/path/to/project"
onChange={(event) => setPathInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleOpen();
}
}}
style={{ width: "100%", padding: "10px", marginTop: "12px" }}
/>
<button type="button" onClick={handleOpen} disabled={isOpening}>
{isOpening ? "Opening..." : "Open Project"}
</button>
</div>
) : (
<div className="workspace" style={{ height: "100%" }}>
<Chat projectPath={projectPath} onCloseProject={closeProject} />
</div>
)}
{errorMsg && (
<div className="error-message" style={{ marginTop: "20px" }}>
<p style={{ color: "red" }}>Error: {errorMsg}</p>
</div>
)}
</main>
);
}
export default App;

View File

@@ -1,226 +1,276 @@
export type WsRequest =
| {
type: "chat";
messages: Message[];
config: ProviderConfig;
}
| {
type: "cancel";
};
| {
type: "chat";
messages: Message[];
config: ProviderConfig;
}
| {
type: "cancel";
};
export type WsResponse =
| { type: "token"; content: string }
| { type: "update"; messages: Message[] }
| { type: "error"; message: string };
| { type: "token"; content: string }
| { type: "update"; messages: Message[] }
| { type: "error"; message: string };
export interface ProviderConfig {
provider: string;
model: string;
base_url?: string;
enable_tools?: boolean;
provider: string;
model: string;
base_url?: string;
enable_tools?: boolean;
}
export type Role = "system" | "user" | "assistant" | "tool";
export interface ToolCall {
id?: string;
type: string;
function: {
name: string;
arguments: string;
};
id?: string;
type: string;
function: {
name: string;
arguments: string;
};
}
export interface Message {
role: Role;
content: string;
tool_calls?: ToolCall[];
tool_call_id?: string;
role: Role;
content: string;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
export interface FileEntry {
name: string;
kind: "file" | "dir";
name: string;
kind: "file" | "dir";
}
export interface SearchResult {
path: string;
matches: number;
path: string;
matches: number;
}
export interface CommandOutput {
stdout: string;
stderr: string;
exit_code: number;
stdout: string;
stderr: string;
exit_code: number;
}
const DEFAULT_API_BASE = "/api";
const DEFAULT_WS_PATH = "/ws";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
return res.json() as Promise<T>;
}
export const api = {
getCurrentProject(baseUrl?: string) {
return requestJson<string | null>("/project", {}, baseUrl);
},
openProject(path: string, baseUrl?: string) {
return requestJson<string>(
"/project",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
closeProject(baseUrl?: string) {
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
},
getModelPreference(baseUrl?: string) {
return requestJson<string | null>("/model", {}, baseUrl);
},
setModelPreference(model: string, baseUrl?: string) {
return requestJson<boolean>(
"/model",
{ method: "POST", body: JSON.stringify({ model }) },
baseUrl,
);
},
getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
const url = new URL(
buildApiUrl("/ollama/models", baseUrl),
window.location.origin,
);
if (baseUrlParam) {
url.searchParams.set("base_url", baseUrlParam);
}
return requestJson<string[]>(url.pathname + url.search, {}, "");
},
getAnthropicApiKeyExists(baseUrl?: string) {
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
},
setAnthropicApiKey(api_key: string, baseUrl?: string) {
return requestJson<boolean>(
"/anthropic/key",
{ method: "POST", body: JSON.stringify({ api_key }) },
baseUrl,
);
},
readFile(path: string, baseUrl?: string) {
return requestJson<string>(
"/fs/read",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
writeFile(path: string, content: string, baseUrl?: string) {
return requestJson<boolean>(
"/fs/write",
{ method: "POST", body: JSON.stringify({ path, content }) },
baseUrl,
);
},
listDirectory(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/fs/list",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
searchFiles(query: string, baseUrl?: string) {
return requestJson<SearchResult[]>(
"/fs/search",
{ method: "POST", body: JSON.stringify({ query }) },
baseUrl,
);
},
execShell(command: string, args: string[], baseUrl?: string) {
return requestJson<CommandOutput>(
"/shell/exec",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
},
cancelChat(baseUrl?: string) {
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
},
getCurrentProject(baseUrl?: string) {
return requestJson<string | null>("/project", {}, baseUrl);
},
getKnownProjects(baseUrl?: string) {
return requestJson<string[]>("/projects", {}, baseUrl);
},
openProject(path: string, baseUrl?: string) {
return requestJson<string>(
"/project",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
closeProject(baseUrl?: string) {
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
},
getModelPreference(baseUrl?: string) {
return requestJson<string | null>("/model", {}, baseUrl);
},
setModelPreference(model: string, baseUrl?: string) {
return requestJson<boolean>(
"/model",
{ method: "POST", body: JSON.stringify({ model }) },
baseUrl,
);
},
getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
const url = new URL(
buildApiUrl("/ollama/models", baseUrl),
window.location.origin,
);
if (baseUrlParam) {
url.searchParams.set("base_url", baseUrlParam);
}
return requestJson<string[]>(url.pathname + url.search, {}, "");
},
getAnthropicApiKeyExists(baseUrl?: string) {
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
},
getAnthropicModels(baseUrl?: string) {
return requestJson<string[]>("/anthropic/models", {}, baseUrl);
},
setAnthropicApiKey(api_key: string, baseUrl?: string) {
return requestJson<boolean>(
"/anthropic/key",
{ method: "POST", body: JSON.stringify({ api_key }) },
baseUrl,
);
},
readFile(path: string, baseUrl?: string) {
return requestJson<string>(
"/fs/read",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
writeFile(path: string, content: string, baseUrl?: string) {
return requestJson<boolean>(
"/fs/write",
{ method: "POST", body: JSON.stringify({ path, content }) },
baseUrl,
);
},
listDirectory(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/fs/list",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
searchFiles(query: string, baseUrl?: string) {
return requestJson<SearchResult[]>(
"/fs/search",
{ method: "POST", body: JSON.stringify({ query }) },
baseUrl,
);
},
execShell(command: string, args: string[], baseUrl?: string) {
return requestJson<CommandOutput>(
"/shell/exec",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
},
cancelChat(baseUrl?: string) {
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
},
};
export class ChatWebSocket {
private socket?: WebSocket;
private onToken?: (content: string) => void;
private onUpdate?: (messages: Message[]) => void;
private onError?: (message: string) => void;
private static sharedSocket: WebSocket | null = null;
private static refCount = 0;
private socket?: WebSocket;
private onToken?: (content: string) => void;
private onUpdate?: (messages: Message[]) => void;
private onError?: (message: string) => void;
private connected = false;
private closeTimer?: number;
connect(
handlers: {
onToken?: (content: string) => void;
onUpdate?: (messages: Message[]) => void;
onError?: (message: string) => void;
},
wsPath = DEFAULT_WS_PATH,
) {
this.onToken = handlers.onToken;
this.onUpdate = handlers.onUpdate;
this.onError = handlers.onError;
connect(
handlers: {
onToken?: (content: string) => void;
onUpdate?: (messages: Message[]) => void;
onError?: (message: string) => void;
},
wsPath = DEFAULT_WS_PATH,
) {
this.onToken = handlers.onToken;
this.onUpdate = handlers.onUpdate;
this.onError = handlers.onError;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${protocol}://${window.location.host}${wsPath}`;
this.socket = new WebSocket(wsUrl);
if (this.connected) {
return;
}
this.connected = true;
ChatWebSocket.refCount += 1;
this.socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as WsResponse;
if (data.type === "token") this.onToken?.(data.content);
if (data.type === "update") this.onUpdate?.(data.messages);
if (data.type === "error") this.onError?.(data.message);
} catch (err) {
this.onError?.(String(err));
}
};
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const wsHost = import.meta.env.DEV
? "127.0.0.1:3001"
: window.location.host;
const wsUrl = `${protocol}://${wsHost}${wsPath}`;
this.socket.onerror = () => {
this.onError?.("WebSocket error");
};
}
if (
!ChatWebSocket.sharedSocket ||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED ||
ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING
) {
ChatWebSocket.sharedSocket = new WebSocket(wsUrl);
}
this.socket = ChatWebSocket.sharedSocket;
sendChat(messages: Message[], config: ProviderConfig) {
this.send({ type: "chat", messages, config });
}
this.socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as WsResponse;
if (data.type === "token") this.onToken?.(data.content);
if (data.type === "update") this.onUpdate?.(data.messages);
if (data.type === "error") this.onError?.(data.message);
} catch (err) {
this.onError?.(String(err));
}
};
cancel() {
this.send({ type: "cancel" });
}
this.socket.onerror = () => {
this.onError?.("WebSocket error");
};
}
close() {
this.socket?.close();
}
sendChat(messages: Message[], config: ProviderConfig) {
this.send({ type: "chat", messages, config });
}
private send(payload: WsRequest) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
this.onError?.("WebSocket is not connected");
return;
}
this.socket.send(JSON.stringify(payload));
}
cancel() {
this.send({ type: "cancel" });
}
close() {
if (!this.connected) return;
this.connected = false;
ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1);
if (import.meta.env.DEV) {
if (this.closeTimer) {
window.clearTimeout(this.closeTimer);
}
this.closeTimer = window.setTimeout(() => {
if (ChatWebSocket.refCount === 0) {
ChatWebSocket.sharedSocket?.close();
ChatWebSocket.sharedSocket = null;
}
this.socket = ChatWebSocket.sharedSocket ?? undefined;
this.closeTimer = undefined;
}, 250);
return;
}
if (ChatWebSocket.refCount === 0) {
ChatWebSocket.sharedSocket?.close();
ChatWebSocket.sharedSocket = null;
}
this.socket = ChatWebSocket.sharedSocket ?? undefined;
}
private send(payload: WsRequest) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
this.onError?.("WebSocket is not connected");
return;
}
this.socket.send(JSON.stringify(payload));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -3,18 +3,14 @@ import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig(() => ({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:3001",
"/ws": {
target: "ws://localhost:3001",
ws: true,
},
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
plugins: [react()],
server: {
proxy: {
"/api": "http://127.0.0.1:3001",
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
}));