From 56fc494e0c87f200bb16994ea03c3b306bf3ae48 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 7 May 2026 11:31:31 +0200 Subject: [PATCH] Refactor client components and fix layout --- package.json | 2 +- src/client/index.html | 3 + src/client/src/components/ChatView.ts | 19 ++ src/client/src/components/Composer.ts | 41 ++++ src/client/src/components/PiWebApp.ts | 191 +++++++++++++++++ src/client/src/components/ProjectList.ts | 26 +++ src/client/src/components/SessionList.ts | 28 +++ src/client/src/components/WorkspaceList.ts | 26 +++ src/client/src/components/shared.ts | 50 +++++ src/client/src/main.ts | 225 +-------------------- src/client/src/route.ts | 27 +++ src/client/src/sessionSocket.ts | 29 +++ vite.config.ts | 1 + 13 files changed, 443 insertions(+), 225 deletions(-) create mode 100644 src/client/src/components/ChatView.ts create mode 100644 src/client/src/components/Composer.ts create mode 100644 src/client/src/components/PiWebApp.ts create mode 100644 src/client/src/components/ProjectList.ts create mode 100644 src/client/src/components/SessionList.ts create mode 100644 src/client/src/components/WorkspaceList.ts create mode 100644 src/client/src/components/shared.ts create mode 100644 src/client/src/route.ts create mode 100644 src/client/src/sessionSocket.ts diff --git a/package.json b/package.json index 79e71e3..cb8d0e8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "sh -c 'npm run dev:server & npm run dev:client & wait'", + "dev": "bash -c 'trap \"kill 0\" EXIT; npm run dev:server & npm run dev:client & wait'", "dev:server": "tsx watch src/server/index.ts", "dev:client": "vite --host 0.0.0.0", "build": "tsc && vite build", diff --git a/src/client/index.html b/src/client/index.html index 9d6b434..58b6f29 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -4,6 +4,9 @@ Pi Web POC + diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts new file mode 100644 index 0000000..cbc55c6 --- /dev/null +++ b/src/client/src/components/ChatView.ts @@ -0,0 +1,19 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { ChatLine } from "./shared"; +import { chatStyles } from "./shared"; + +@customElement("chat-view") +export class ChatView extends LitElement { + @property({ attribute: false }) messages: ChatLine[] = []; + + render() { + return html` +
+ ${this.messages.map((message) => html`
${message.role}
${message.text}
`)} +
+ `; + } + + static styles = chatStyles; +} diff --git a/src/client/src/components/Composer.ts b/src/client/src/components/Composer.ts new file mode 100644 index 0000000..af57b6c --- /dev/null +++ b/src/client/src/components/Composer.ts @@ -0,0 +1,41 @@ +import { LitElement, html } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { composerStyles } from "./shared"; + +@customElement("chat-composer") +export class Composer extends LitElement { + @property({ type: Boolean }) disabled = false; + @property({ attribute: false }) onSend?: (text: string) => void; + @property({ attribute: false }) onCloseSession?: () => void; + @state() private draft = ""; + + render() { + return html` + + `; + } + + private send() { + const text = this.draft.trim(); + if (!text || this.disabled) return; + this.draft = ""; + this.onSend?.(text); + } + + static styles = composerStyles; +} diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts new file mode 100644 index 0000000..71a8b0b --- /dev/null +++ b/src/client/src/components/PiWebApp.ts @@ -0,0 +1,191 @@ +import { LitElement, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { api, type Project, type SessionInfo, type Workspace } from "../api"; +import { readRoute, writeRoute } from "../route"; +import { SessionSocket, type SessionUiEvent } from "../sessionSocket"; +import "./ProjectList"; +import "./WorkspaceList"; +import "./SessionList"; +import "./ChatView"; +import "./Composer"; +import { appStyles, type ChatLine } from "./shared"; + +@customElement("pi-web-poc") +export class PiWebApp extends LitElement { + @state() private projects: Project[] = []; + @state() private workspaces: Workspace[] = []; + @state() private sessions: SessionInfo[] = []; + @state() private messages: ChatLine[] = []; + @state() private selectedProject?: Project; + @state() private selectedWorkspace?: Workspace; + @state() private selectedSession?: SessionInfo; + @state() private error = ""; + + private readonly socket = new SessionSocket(); + private readonly onPopState = () => void this.restoreRoute(false); + + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener("popstate", this.onPopState); + void this.loadProjects(); + } + + disconnectedCallback(): void { + window.removeEventListener("popstate", this.onPopState); + this.socket.close(); + super.disconnectedCallback(); + } + + private async loadProjects() { + this.error = ""; + try { + this.projects = await api.projects(); + await this.restoreRoute(false); + } catch (error) { + this.error = String(error); + } + } + + private async addProject() { + const path = prompt("Project folder path"); + if (!path) return; + try { + const project = await api.addProject(path); + this.projects = [...this.projects.filter((p) => p.id !== project.id), project]; + await this.selectProject(project); + } catch (error) { + this.error = String(error); + } + } + + private async selectProject(project: Project, target?: { workspaceId?: string; sessionId?: string; updateUrl?: boolean }) { + this.selectedProject = project; + this.selectedWorkspace = undefined; + this.selectedSession = undefined; + this.sessions = []; + this.messages = []; + this.socket.close(); + try { + this.workspaces = await api.workspaces(project.id); + const workspace = target?.workspaceId ? this.workspaces.find((w) => w.id === target.workspaceId) : this.workspaces[0]; + if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl }); + else if (target?.updateUrl !== false) this.updateUrl(); + } catch (error) { + this.error = String(error); + } + } + + private async selectWorkspace(workspace: Workspace, target?: { sessionId?: string; updateUrl?: boolean }) { + this.selectedWorkspace = workspace; + this.selectedSession = undefined; + this.messages = []; + this.socket.close(); + try { + this.sessions = await api.sessions(workspace.path); + const sessionId = target?.sessionId; + const session = sessionId ? this.sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : undefined; + if (session) await this.selectSession(session, { updateUrl: target?.updateUrl }); + else if (target?.updateUrl !== false) this.updateUrl(); + } catch (error) { + this.error = String(error); + } + } + + private async startSession() { + if (!this.selectedWorkspace) return; + try { + const session = await api.startSession(this.selectedWorkspace.path); + this.sessions = [session, ...this.sessions]; + await this.selectSession(session); + } catch (error) { + this.error = String(error); + } + } + + private async selectSession(session: SessionInfo, options?: { updateUrl?: boolean }) { + this.selectedSession = session; + this.socket.close(); + this.messages = normalizeMessages(await api.messages(session.id)); + this.socket.connect(session.id, (event) => this.applyEvent(event)); + if (options?.updateUrl !== false) this.updateUrl(); + } + + private applyEvent(event: SessionUiEvent) { + if (event.type === "assistant.delta") { + const lines = [...this.messages]; + const last = lines.at(-1); + if (last?.role === "assistant") last.text += event.text; + else lines.push({ role: "assistant", text: event.text }); + this.messages = lines; + } else if (event.type === "tool.start") { + this.messages = [...this.messages, { role: "tool", text: `▶ ${event.toolName}` }]; + } else if (event.type === "tool.end") { + this.messages = [...this.messages, { role: "tool", text: `${event.isError ? "✖" : "✓"} ${event.toolName}` }]; + } else if (event.type === "session.error") { + this.messages = [...this.messages, { role: "system", text: event.message }]; + } + } + + private async send(text: string) { + if (!this.selectedSession) return; + this.messages = [...this.messages, { role: "user", text }]; + try { + await api.prompt(this.selectedSession.id, text); + } catch (error) { + this.error = String(error); + } + } + + private async closeSession() { + if (!this.selectedSession) return; + await api.close(this.selectedSession.id); + this.selectedSession = undefined; + this.socket.close(); + this.messages = []; + this.updateUrl(); + } + + private async restoreRoute(updateUrl: boolean) { + const route = readRoute(); + if (!route.projectId) return; + const project = this.projects.find((p) => p.id === route.projectId); + if (!project) return; + await this.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); + } + + private updateUrl() { + writeRoute({ projectId: this.selectedProject?.id, workspaceId: this.selectedWorkspace?.id, sessionId: this.selectedSession?.id }); + } + + render() { + return html` +
+ +
+ ${this.error ? html`
${this.error}
` : null} + ${this.selectedSession ? html` + + this.send(text)} .onCloseSession=${() => this.closeSession()}> + ` : html`
Select or start a session.
`} +
+
+ `; + } + + static styles = appStyles; +} + +function normalizeMessages(messages: any[]): ChatLine[] { + return messages.map((message) => ({ + role: message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : "system", + text: typeof message.content === "string" ? message.content : JSON.stringify(message.content, null, 2), + })); +} diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts new file mode 100644 index 0000000..5baf830 --- /dev/null +++ b/src/client/src/components/ProjectList.ts @@ -0,0 +1,26 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { Project } from "../api"; +import { listStyles } from "./shared"; + +@customElement("project-list") +export class ProjectList extends LitElement { + @property({ attribute: false }) projects: Project[] = []; + @property({ attribute: false }) selected?: Project; + @property({ attribute: false }) onSelect?: (project: Project) => void; + + render() { + return html` +
+

Projects

+ ${this.projects.map((project) => html` + + `)} +
+ `; + } + + static styles = listStyles; +} diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts new file mode 100644 index 0000000..54b5f6f --- /dev/null +++ b/src/client/src/components/SessionList.ts @@ -0,0 +1,28 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { SessionInfo } from "../api"; +import { listStyles } from "./shared"; + +@customElement("session-list") +export class SessionList extends LitElement { + @property({ attribute: false }) sessions: SessionInfo[] = []; + @property({ attribute: false }) selected?: SessionInfo; + @property({ type: Boolean }) canStart = false; + @property({ attribute: false }) onSelect?: (session: SessionInfo) => void; + @property({ attribute: false }) onStart?: () => void; + + render() { + return html` +
+

Sessions

+ ${this.sessions.map((session) => html` + + `)} +
+ `; + } + + static styles = listStyles; +} diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts new file mode 100644 index 0000000..c0b36e5 --- /dev/null +++ b/src/client/src/components/WorkspaceList.ts @@ -0,0 +1,26 @@ +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { Workspace } from "../api"; +import { listStyles } from "./shared"; + +@customElement("workspace-list") +export class WorkspaceList extends LitElement { + @property({ attribute: false }) workspaces: Workspace[] = []; + @property({ attribute: false }) selected?: Workspace; + @property({ attribute: false }) onSelect?: (workspace: Workspace) => void; + + render() { + return html` +
+

Workspaces

+ ${this.workspaces.map((workspace) => html` + + `)} +
+ `; + } + + static styles = listStyles; +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts new file mode 100644 index 0000000..f882f90 --- /dev/null +++ b/src/client/src/components/shared.ts @@ -0,0 +1,50 @@ +import { css } from "lit"; + +export interface ChatLine { + role: "user" | "assistant" | "tool" | "system"; + text: string; +} + +export const appStyles = css` + :host { display: block; height: 100vh; color: #e6edf3; background: #0d1117; font: 14px system-ui, sans-serif; } + .shell { display: grid; grid-template-columns: 340px 1fr; height: 100%; min-height: 0; } + aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid #30363d; overflow: hidden; } + header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid #30363d; } + project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid #21262d; } + session-list { flex: 1 1 auto; min-height: 0; overflow: auto; } + main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } + chat-view { flex: 1 1 auto; min-height: 0; overflow: auto; } + chat-composer { flex: 0 0 auto; } + button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } + .empty { margin: auto; color: #8b949e; } + .error { padding: 10px 16px; border-bottom: 1px solid #30363d; color: #ff7b72; } +`; + +export const listStyles = css` + :host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; } + section { padding: 10px; } + h2 { display: flex; justify-content: space-between; align-items: center; margin: 0 0 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; } + button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } + section > button { display: block; width: 100%; text-align: left; margin: 6px 0; } + button.selected { border-color: #58a6ff; background: #0d2847; } + button:disabled { opacity: .5; cursor: not-allowed; } + small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +`; + +export const chatStyles = css` + :host { display: block; min-height: 0; color: #e6edf3; font: 14px system-ui, sans-serif; } + .chat { height: 100%; overflow: auto; padding: 16px; box-sizing: border-box; } + .msg { margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; } + .msg.user { border-color: #2f81f7; } + .msg.tool { color: #d29922; } + .msg.system { color: #ff7b72; } + pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; } +`; + +export const composerStyles = css` + :host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; } + footer { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; padding: 12px; border-top: 1px solid #30363d; } + textarea { min-height: 54px; resize: vertical; border-radius: 8px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; padding: 8px; } + button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } + button:disabled, textarea:disabled { opacity: .5; cursor: not-allowed; } +`; diff --git a/src/client/src/main.ts b/src/client/src/main.ts index 8876890..262c3fa 100644 --- a/src/client/src/main.ts +++ b/src/client/src/main.ts @@ -1,224 +1 @@ -import { LitElement, css, html } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import { api, sessionEvents, type Project, type SessionInfo, type Workspace } from "./api"; - -interface ChatLine { - role: "user" | "assistant" | "tool" | "system"; - text: string; -} - -@customElement("pi-web-poc") -class PiWebPoc extends LitElement { - @state() private projects: Project[] = []; - @state() private workspaces: Workspace[] = []; - @state() private sessions: SessionInfo[] = []; - @state() private messages: ChatLine[] = []; - @state() private selectedProject?: Project; - @state() private selectedWorkspace?: Workspace; - @state() private selectedSession?: SessionInfo; - @state() private error = ""; - @state() private draft = ""; - private socket?: WebSocket; - - connectedCallback(): void { - super.connectedCallback(); - void this.loadProjects(); - } - - disconnectedCallback(): void { - this.socket?.close(); - super.disconnectedCallback(); - } - - private async loadProjects() { - this.error = ""; - try { - this.projects = await api.projects(); - } catch (error) { - this.error = String(error); - } - } - - private async addProject() { - const path = prompt("Project folder path"); - if (!path) return; - try { - const project = await api.addProject(path); - this.projects = [...this.projects.filter((p) => p.id !== project.id), project]; - await this.selectProject(project); - } catch (error) { - this.error = String(error); - } - } - - private async selectProject(project: Project) { - this.selectedProject = project; - this.selectedWorkspace = undefined; - this.selectedSession = undefined; - this.sessions = []; - this.messages = []; - try { - this.workspaces = await api.workspaces(project.id); - if (this.workspaces[0]) await this.selectWorkspace(this.workspaces[0]); - } catch (error) { - this.error = String(error); - } - } - - private async selectWorkspace(workspace: Workspace) { - this.selectedWorkspace = workspace; - this.selectedSession = undefined; - this.messages = []; - try { - this.sessions = await api.sessions(workspace.path); - } catch (error) { - this.error = String(error); - } - } - - private async startSession() { - if (!this.selectedWorkspace) return; - try { - const session = await api.startSession(this.selectedWorkspace.path); - this.sessions = [session, ...this.sessions]; - await this.selectSession(session); - } catch (error) { - this.error = String(error); - } - } - - private async selectSession(session: SessionInfo) { - this.selectedSession = session; - this.socket?.close(); - this.messages = normalizeMessages(await api.messages(session.id)); - this.socket = sessionEvents(session.id); - this.socket.onmessage = (message) => this.applyEvent(JSON.parse(message.data)); - } - - private applyEvent(event: any) { - if (event.type === "assistant.delta") { - const lines = [...this.messages]; - const last = lines.at(-1); - if (last?.role === "assistant") last.text += event.text; - else lines.push({ role: "assistant", text: event.text }); - this.messages = lines; - } else if (event.type === "tool.start") { - this.messages = [...this.messages, { role: "tool", text: `▶ ${event.toolName}` }]; - } else if (event.type === "tool.end") { - this.messages = [...this.messages, { role: "tool", text: `${event.isError ? "✖" : "✓"} ${event.toolName}` }]; - } else if (event.type === "session.error") { - this.messages = [...this.messages, { role: "system", text: event.message }]; - } - } - - private async send() { - const text = this.draft.trim(); - if (!text || !this.selectedSession) return; - this.draft = ""; - this.messages = [...this.messages, { role: "user", text }]; - try { - await api.prompt(this.selectedSession.id, text); - } catch (error) { - this.error = String(error); - } - } - - private async closeSession() { - if (!this.selectedSession) return; - await api.close(this.selectedSession.id); - this.selectedSession = undefined; - this.socket?.close(); - this.messages = []; - } - - render() { - return html` -
- - -
- ${this.error ? html`
${this.error}
` : null} - ${this.selectedSession ? html` -
- ${this.messages.map((message) => html`
${message.role}
${message.text}
`)} -
-
- - - -
- ` : html`
Select or start a session.
`} -
-
- `; - } - - static styles = css` - :host { display: block; height: 100vh; color: #e6edf3; background: #0d1117; font: 14px system-ui, sans-serif; } - .shell { display: grid; grid-template-columns: 340px 1fr; height: 100%; } - aside { border-right: 1px solid #30363d; overflow: auto; } - header { display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid #30363d; } - section { padding: 10px; border-bottom: 1px solid #21262d; } - h2 { display: flex; justify-content: space-between; align-items: center; margin: 0 0 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; } - button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } - section > button { display: block; width: 100%; text-align: left; margin: 6px 0; } - button.selected { border-color: #58a6ff; background: #0d2847; } - button:disabled { opacity: .5; cursor: not-allowed; } - small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - main { display: flex; flex-direction: column; min-width: 0; } - .chat { flex: 1; overflow: auto; padding: 16px; } - .msg { margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; } - .msg.user { border-color: #2f81f7; } - .msg.tool { color: #d29922; } - .msg.system, .error { color: #ff7b72; } - pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; } - footer { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; padding: 12px; border-top: 1px solid #30363d; } - textarea { min-height: 54px; resize: vertical; border-radius: 8px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; padding: 8px; } - .empty { margin: auto; color: #8b949e; } - .error { padding: 10px 16px; border-bottom: 1px solid #30363d; } - `; -} - -function normalizeMessages(messages: any[]): ChatLine[] { - return messages.map((message) => ({ - role: message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : "system", - text: typeof message.content === "string" ? message.content : JSON.stringify(message.content, null, 2), - })); -} +import "./components/PiWebApp"; diff --git a/src/client/src/route.ts b/src/client/src/route.ts new file mode 100644 index 0000000..12cdbb9 --- /dev/null +++ b/src/client/src/route.ts @@ -0,0 +1,27 @@ +export interface AppRoute { + projectId?: string; + workspaceId?: string; + sessionId?: string; +} + +export function readRoute(): AppRoute { + const params = new URLSearchParams(window.location.search); + return { + projectId: params.get("project") ?? undefined, + workspaceId: params.get("workspace") ?? undefined, + sessionId: params.get("session") ?? undefined, + }; +} + +export function writeRoute(route: AppRoute): void { + const url = new URL(window.location.href); + url.searchParams.delete("project"); + url.searchParams.delete("workspace"); + url.searchParams.delete("session"); + if (route.projectId) url.searchParams.set("project", route.projectId); + if (route.workspaceId) url.searchParams.set("workspace", route.workspaceId); + if (route.sessionId) url.searchParams.set("session", route.sessionId); + const next = `${url.pathname}${url.search}${url.hash}`; + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (next !== current) window.history.pushState({}, "", url); +} diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts new file mode 100644 index 0000000..083310f --- /dev/null +++ b/src/client/src/sessionSocket.ts @@ -0,0 +1,29 @@ +import { sessionEvents } from "./api"; + +export type SessionUiEvent = + | { type: "assistant.delta"; text: string } + | { type: "tool.start"; toolName: string } + | { type: "tool.end"; toolName: string; isError: boolean } + | { type: "session.error"; message: string }; + +export class SessionSocket { + private socket?: WebSocket; + + connect(sessionId: string, onEvent: (event: SessionUiEvent) => void): void { + this.close(); + this.socket = sessionEvents(sessionId); + this.socket.onmessage = (message) => { + const event = JSON.parse(message.data); + if (isSessionUiEvent(event)) onEvent(event); + }; + } + + close(): void { + this.socket?.close(); + this.socket = undefined; + } +} + +function isSessionUiEvent(event: any): event is SessionUiEvent { + return ["assistant.delta", "tool.start", "tool.end", "session.error"].includes(event?.type); +} diff --git a/vite.config.ts b/vite.config.ts index f0ab98f..5dc767f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ }, server: { port: 5173, + strictPort: true, proxy: { "/api": "http://localhost:3000", },