Add paged chat history loading

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 15:46:08 +02:00
parent 5430a44561
commit eb59f1eb00
42 changed files with 1894 additions and 333 deletions
+47
View File
@@ -0,0 +1,47 @@
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import globals from "globals";
import tseslint from "typescript-eslint";
export default defineConfig([
{
ignores: ["dist/**", "node_modules/**"],
},
{
files: ["src/**/*.ts", "vite.config.ts"],
extends: [
js.configs.recommended,
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/no-unsafe-argument": "error",
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
"@typescript-eslint/strict-boolean-expressions": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }],
"@typescript-eslint/restrict-template-expressions": "error",
"@typescript-eslint/restrict-plus-operands": "error",
},
},
]);
+989
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -10,6 +10,8 @@
"dev:server": "npm run dev:web", "dev:server": "npm run dev:web",
"dev:client": "vite --host 0.0.0.0", "dev:client": "vite --host 0.0.0.0",
"build": "tsc && vite build", "build": "tsc && vite build",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.ts\" vite.config.ts",
"start": "tsx src/server/index.ts", "start": "tsx src/server/index.ts",
"start:sessiond": "tsx src/server/sessiond.ts" "start:sessiond": "tsx src/server/sessiond.ts"
}, },
@@ -23,10 +25,14 @@
"ws": "^8.18.3" "ws": "^8.18.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"eslint": "^10.3.0",
"globals": "^17.6.0",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.59.2",
"vite": "^7.2.4" "vite": "^7.2.4"
} }
} }
+228 -22
View File
@@ -64,38 +64,49 @@ export interface CommandOption {
description?: string; description?: string;
} }
export interface MessagePage {
messages: unknown[];
start: number;
total: number;
}
export type CommandResult = export type CommandResult =
| { type: "done"; message?: string; session?: SessionInfo } | { type: "done"; message?: string; session?: SessionInfo }
| { type: "select"; requestId: string; title: string; options: CommandOption[] } | { type: "select"; requestId: string; title: string; options: CommandOption[] }
| { type: "unsupported"; message: string }; | { type: "unsupported"; message: string };
async function request<T>(url: string, init?: RequestInit): Promise<T> { async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
const response = await fetch(url, { const headers = new Headers(init?.headers);
...init, headers.set("content-type", "application/json");
headers: { "content-type": "application/json", ...init?.headers }, const response = await fetch(url, { ...init, headers });
});
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => ({})); const body: unknown = await response.json().catch((): unknown => ({}));
throw new Error(body.error ?? response.statusText); throw new Error(errorMessage(body) ?? response.statusText);
} }
return response.json() as Promise<T>; const body: unknown = await response.json();
return parse(body);
}
function errorMessage(value: unknown): string | undefined {
if (!isRecord(value)) return undefined;
return typeof value["error"] === "string" ? value["error"] : undefined;
} }
export const api = { export const api = {
projects: () => request<Project[]>("/api/projects"), projects: () => request("/api/projects", arrayOf(parseProject)),
addProject: (path: string, name?: string) => request<Project>("/api/projects", { method: "POST", body: JSON.stringify({ path, name }) }), addProject: (path: string, name?: string) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name }) }),
workspaces: (projectId: string) => request<Workspace[]>(`/api/projects/${projectId}/workspaces`), workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
sessions: (cwd: string) => request<SessionInfo[]>(`/api/sessions?cwd=${encodeURIComponent(cwd)}`), sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
startSession: (cwd: string) => request<SessionInfo>("/api/sessions", { method: "POST", body: JSON.stringify({ cwd }) }), startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
messages: (sessionId: string) => request<any[]>(`/api/sessions/${sessionId}/messages`), messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
status: (sessionId: string) => request<SessionStatus>(`/api/sessions/${sessionId}/status`), status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
commands: (sessionId: string) => request<SlashCommand[]>(`/api/sessions/${sessionId}/commands`), commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request<FileSuggestion[]>(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind ? `&kind=${encodeURIComponent(kind)}` : ""}`), files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}`, arrayOf(parseFileSuggestion)),
prompt: (sessionId: string, text: string) => request<{ accepted: true }>(`/api/sessions/${sessionId}/prompt`, { method: "POST", body: JSON.stringify({ text }) }), prompt: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
shell: (sessionId: string, text: string) => request<{ accepted: true }>(`/api/sessions/${sessionId}/shell`, { method: "POST", body: JSON.stringify({ text }) }), shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
runCommand: (sessionId: string, text: string) => request<CommandResult>(`/api/sessions/${sessionId}/commands/run`, { method: "POST", body: JSON.stringify({ text }) }), runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
respondToCommand: (sessionId: string, requestId: string, value: string) => request<CommandResult>(`/api/sessions/${sessionId}/commands/respond`, { method: "POST", body: JSON.stringify({ requestId, value }) }), respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
stop: (sessionId: string) => request<{ stopped: true }>(`/api/sessions/${sessionId}/stop`, { method: "POST" }), stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
}; };
export function sessionEvents(sessionId: string): WebSocket { export function sessionEvents(sessionId: string): WebSocket {
@@ -106,7 +117,202 @@ export function globalSessionEvents(): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`); return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
} }
function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.before !== undefined) params.set("before", String(options.before));
const query = params.toString();
return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
}
function webSocketBaseUrl(): string { function webSocketBaseUrl(): string {
const protocol = location.protocol === "https:" ? "wss:" : "ws:"; const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${location.host}`; return `${protocol}//${location.host}`;
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function requireRecord(value: unknown): Record<string, unknown> {
if (!isRecord(value)) throw new Error("Expected object response");
return value;
}
function requireString(record: Record<string, unknown>, key: string): string {
const value = record[key];
if (typeof value !== "string") throw new Error(`Expected string field: ${key}`);
return value;
}
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
if (value === undefined) return undefined;
if (typeof value !== "string") throw new Error(`Expected optional string field: ${key}`);
return value;
}
function requireNumber(record: Record<string, unknown>, key: string): number {
const value = record[key];
if (typeof value !== "number") throw new Error(`Expected number field: ${key}`);
return value;
}
function requireBoolean(record: Record<string, unknown>, key: string): boolean {
const value = record[key];
if (typeof value !== "boolean") throw new Error(`Expected boolean field: ${key}`);
return value;
}
function arrayOf<T>(parse: (value: unknown) => T): (value: unknown) => T[] {
return (value) => {
if (!Array.isArray(value)) throw new Error("Expected array response");
return value.map(parse);
};
}
function parseUnknownArray(value: unknown): unknown[] {
if (!Array.isArray(value)) throw new Error("Expected array response");
return value;
}
function parseMessagePage(value: unknown): MessagePage {
if (Array.isArray(value)) return { messages: value, start: 0, total: value.length };
const record = requireRecord(value);
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
}
function parseProject(value: unknown): Project {
const record = requireRecord(value);
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
}
function parseWorkspace(value: unknown): Workspace {
const record = requireRecord(value);
const branch = optionalString(record, "branch");
return {
id: requireString(record, "id"),
projectId: requireString(record, "projectId"),
path: requireString(record, "path"),
label: requireString(record, "label"),
...(branch === undefined ? {} : { branch }),
isMain: requireBoolean(record, "isMain"),
isGitWorktree: requireBoolean(record, "isGitWorktree"),
};
}
function parseSessionInfo(value: unknown): SessionInfo {
const record = requireRecord(value);
const name = optionalString(record, "name");
return {
id: requireString(record, "id"),
path: requireString(record, "path"),
cwd: requireString(record, "cwd"),
...(name === undefined ? {} : { name }),
created: requireString(record, "created"),
modified: requireString(record, "modified"),
messageCount: requireNumber(record, "messageCount"),
firstMessage: requireString(record, "firstMessage"),
};
}
function parseSessionStatus(value: unknown): SessionStatus {
const record = requireRecord(value);
return {
sessionId: requireString(record, "sessionId"),
isStreaming: requireBoolean(record, "isStreaming"),
isCompacting: requireBoolean(record, "isCompacting"),
isBashRunning: requireBoolean(record, "isBashRunning"),
pendingMessageCount: requireNumber(record, "pendingMessageCount"),
tokens: parseTokens(record["tokens"]),
cost: requireNumber(record, "cost"),
...optionalModel(record["model"]),
...optionalContextUsage(record["contextUsage"]),
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
};
}
function parseTokens(value: unknown): SessionStatus["tokens"] {
const record = requireRecord(value);
return {
input: requireNumber(record, "input"),
output: requireNumber(record, "output"),
cacheRead: requireNumber(record, "cacheRead"),
cacheWrite: requireNumber(record, "cacheWrite"),
total: requireNumber(record, "total"),
};
}
function optionalModel(value: unknown): Pick<SessionStatus, "model"> | object {
if (value === undefined) return {};
const record = requireRecord(value);
return { model: { ...optionalField("provider", optionalString(record, "provider")), ...optionalField("id", optionalString(record, "id")), ...optionalField("name", optionalString(record, "name")), ...optionalField("contextWindow", optionalNumber(record, "contextWindow")), ...optionalField("reasoning", record["reasoning"]) } };
}
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
if (value === undefined) return {};
const record = requireRecord(value);
return { contextUsage: { tokens: numberOrNull(record, "tokens"), contextWindow: requireNumber(record, "contextWindow"), percent: numberOrNull(record, "percent") } };
}
function parseSlashCommand(value: unknown): SlashCommand {
const record = requireRecord(value);
const source = requireString(record, "source");
if (source !== "extension" && source !== "prompt" && source !== "skill" && source !== "builtin") throw new Error("Invalid command source");
return { name: requireString(record, "name"), source, ...optionalField("description", optionalString(record, "description")) };
}
function parseFileSuggestion(value: unknown): FileSuggestion {
const record = requireRecord(value);
const kind = requireString(record, "kind");
if (kind !== "tracked" && kind !== "untracked" && kind !== "other") throw new Error("Invalid file kind");
return { path: requireString(record, "path"), kind };
}
function parseCommandResult(value: unknown): CommandResult {
const record = requireRecord(value);
const type = requireString(record, "type");
if (type === "unsupported") return { type, message: requireString(record, "message") };
if (type === "select") return { type, requestId: requireString(record, "requestId"), title: requireString(record, "title"), options: arrayOf(parseCommandOption)(record["options"]) };
if (type === "done") return { type, ...optionalField("message", optionalString(record, "message")), ...optionalSession(record["session"]) };
throw new Error("Invalid command result type");
}
function parseCommandOption(value: unknown): CommandOption {
const record = requireRecord(value);
return { value: requireString(record, "value"), label: requireString(record, "label"), ...optionalField("description", optionalString(record, "description")) };
}
function optionalSession(value: unknown): Pick<Extract<CommandResult, { type: "done" }>, "session"> | object {
return value === undefined ? {} : { session: parseSessionInfo(value) };
}
function parseAccepted(value: unknown): { accepted: true } {
const record = requireRecord(value);
if (record["accepted"] !== true) throw new Error("Expected accepted response");
return { accepted: true };
}
function parseStopped(value: unknown): { stopped: true } {
const record = requireRecord(value);
if (record["stopped"] !== true) throw new Error("Expected stopped response");
return { stopped: true };
}
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
if (value === undefined) return undefined;
if (typeof value !== "number") throw new Error(`Expected optional number field: ${key}`);
return value;
}
function numberOrNull(record: Record<string, unknown>, key: string): number | null {
const value = record[key];
if (value === null) return null;
if (typeof value !== "number") throw new Error(`Expected number|null field: ${key}`);
return value;
}
function optionalField(key: string, value: unknown): object {
return value === undefined ? {} : { [key]: value };
}
+18 -6
View File
@@ -6,14 +6,17 @@ export interface AppState {
workspaces: Workspace[]; workspaces: Workspace[];
sessions: SessionInfo[]; sessions: SessionInfo[];
messages: ChatLine[]; messages: ChatLine[];
selectedProject?: Project; messagePageStart: number;
selectedWorkspace?: Workspace; messagePageTotal: number;
selectedSession?: SessionInfo; isLoadingEarlierMessages: boolean;
status?: SessionStatus; selectedProject: Project | undefined;
activity?: SessionActivity; selectedWorkspace: Workspace | undefined;
selectedSession: SessionInfo | undefined;
status: SessionStatus | undefined;
activity: SessionActivity | undefined;
sessionStatuses: Record<string, SessionStatus>; sessionStatuses: Record<string, SessionStatus>;
sessionActivities: Record<string, SessionActivity>; sessionActivities: Record<string, SessionActivity>;
commandDialog?: Extract<CommandResult, { type: "select" }>; commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
error: string; error: string;
} }
@@ -23,8 +26,17 @@ export function initialAppState(): AppState {
workspaces: [], workspaces: [],
sessions: [], sessions: [],
messages: [], messages: [],
messagePageStart: 0,
messagePageTotal: 0,
isLoadingEarlierMessages: false,
selectedProject: undefined,
selectedWorkspace: undefined,
selectedSession: undefined,
status: undefined,
activity: undefined,
sessionStatuses: {}, sessionStatuses: {},
sessionActivities: {}, sessionActivities: {},
commandDialog: undefined,
error: "", error: "",
}; };
} }
+6 -5
View File
@@ -4,7 +4,7 @@ export type ChatGroup =
| { kind: "message"; message: ChatLine; index: number } | { kind: "message"; message: ChatLine; index: number }
| { kind: "group"; messages: ChatLine[]; startIndex: number }; | { kind: "group"; messages: ChatLine[]; startIndex: number };
export function groupChatMessages(messages: ChatLine[]): ChatGroup[] { export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGroup[] {
const groups: ChatGroup[] = []; const groups: ChatGroup[] = [];
let eventMessages: ChatLine[] = []; let eventMessages: ChatLine[] = [];
let eventStartIndex = 0; let eventStartIndex = 0;
@@ -23,10 +23,11 @@ export function groupChatMessages(messages: ChatLine[]): ChatGroup[] {
const readableParts = message.parts.filter((part) => isReadablePart(message, part)); const readableParts = message.parts.filter((part) => isReadablePart(message, part));
const technicalParts = message.parts.filter((part) => !isReadablePart(message, part)); const technicalParts = message.parts.filter((part) => !isReadablePart(message, part));
if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts }, index); const absoluteIndex = indexOffset + index;
if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts }, absoluteIndex);
if (readableParts.length) { if (readableParts.length) {
flushEvents(); flushEvents();
groups.push({ kind: "message", message: { role: message.role, parts: readableParts }, index }); groups.push({ kind: "message", message: { role: message.role, parts: readableParts }, index: absoluteIndex });
} }
}); });
flushEvents(); flushEvents();
@@ -38,8 +39,8 @@ export function summarizeChatGroup(messages: ChatLine[]): string {
acc[message.role] = (acc[message.role] ?? 0) + 1; acc[message.role] = (acc[message.role] ?? 0) + 1;
return acc; return acc;
}, {}); }, {});
const details = Object.entries(counts).map(([role, count]) => `${count} ${role}`).join(" · "); const details = Object.entries(counts).map(([role, count]) => `${String(count)} ${role}`).join(" · ");
return `${messages.length} ${messages.length === 1 ? "event" : "events"}${details ? ` · ${details}` : ""}`; return `${String(messages.length)} ${messages.length === 1 ? "event" : "events"}${details !== "" ? ` · ${details}` : ""}`;
} }
function isReadablePart(message: ChatLine, part: ChatPart): boolean { function isReadablePart(message: ChatLine, part: ChatPart): boolean {
+68 -31
View File
@@ -1,6 +1,6 @@
import type { ChatLine, ChatPart } from "./components/shared"; import type { ChatLine, ChatPart } from "./components/shared";
export function normalizeMessages(messages: any[]): ChatLine[] { export function normalizeMessages(messages: unknown[]): ChatLine[] {
return messages.flatMap(normalizeMessage).filter((message) => message.parts.length > 0); return messages.flatMap(normalizeMessage).filter((message) => message.parts.length > 0);
} }
@@ -20,23 +20,27 @@ export function appendText(messages: ChatLine[], role: ChatLine["role"], text: s
return [...messages, textMessage(role, text)]; return [...messages, textMessage(role, text)];
} }
function normalizeMessage(message: any): ChatLine[] { function normalizeMessage(message: unknown): ChatLine[] {
if (message?.role === "bashExecution") return [normalizeBashExecution(message)]; if (getString(message, "role") === "bashExecution") return [normalizeBashExecution(message)];
const role = normalizeRole(message?.role); const role = normalizeRole(getString(message, "role"));
const parts = normalizeContent(message?.content, message); const parts = normalizeContent(getProperty(message, "content"), message);
if (role === "tool") return [{ role, parts }]; if (role === "tool") return [{ role, parts }];
const visible = parts.filter((part) => part.type !== "empty"); const visible = parts.filter((part) => part.type !== "empty");
return visible.length ? [{ role, parts: visible }] : []; return visible.length > 0 ? [{ role, parts: visible }] : [];
} }
function normalizeBashExecution(message: any): ChatLine { function normalizeBashExecution(message: unknown): ChatLine {
const lines = message.excludeFromContext ? ["excluded from context", "", `$ ${message.command ?? ""}`] : [`$ ${message.command ?? ""}`]; const command = getString(message, "command") ?? "";
if (message.output) lines.push("", String(message.output)); const lines = getBoolean(message, "excludeFromContext") === true ? ["excluded from context", "", `$ ${command}`] : [`$ ${command}`];
if (message.exitCode != null) lines.push("", `exit ${message.exitCode}`); const output = getProperty(message, "output");
if (message.cancelled) lines.push("", "cancelled"); if (output != null) lines.push("", stringifyPrimitive(output));
if (message.truncated) lines.push("", "output truncated"); const exitCode = getProperty(message, "exitCode");
if (message.fullOutputPath) lines.push("", `full output: ${message.fullOutputPath}`); if (exitCode != null) lines.push("", `exit ${stringifyPrimitive(exitCode)}`);
if (getBoolean(message, "cancelled") === true) lines.push("", "cancelled");
if (getBoolean(message, "truncated") === true) lines.push("", "output truncated");
const fullOutputPath = getString(message, "fullOutputPath");
if (fullOutputPath !== undefined && fullOutputPath !== "") lines.push("", `full output: ${fullOutputPath}`);
return { role: "bash", parts: [{ type: "text", text: lines.join("\n") }] }; return { role: "bash", parts: [{ type: "text", text: lines.join("\n") }] };
} }
@@ -47,33 +51,41 @@ function normalizeRole(role: unknown): ChatLine["role"] {
return "system"; return "system";
} }
function normalizeContent(content: unknown, message: any): ChatPart[] { function normalizeContent(content: unknown, message: unknown): ChatPart[] {
if (typeof content === "string") return content ? [{ type: "text", text: content }] : []; if (typeof content === "string") return content !== "" ? [{ type: "text", text: content }] : [];
if (!Array.isArray(content)) return objectFallback(content); if (!Array.isArray(content)) return objectFallback(content);
return content.flatMap((part: any): ChatPart[] => { return content.flatMap((part): ChatPart[] => {
if (part?.type === "text") return part.text ? [{ type: "text", text: part.text }] : []; const type = getString(part, "type");
if (part?.type === "thinking") return part.thinking || part.text ? [{ type: "thinking", text: part.thinking ?? part.text }] : []; const text = getString(part, "text");
if (part?.type === "toolCall") return [{ type: "toolCall", toolName: part.name ?? "tool", summary: summarizeArgs(part.arguments) }]; if (type === "text") return text !== undefined && text !== "" ? [{ type: "text", text }] : [];
if (part?.type === "image") return [{ type: "text", text: "[image]" }]; if (type === "thinking") {
const thinking = getString(part, "thinking") ?? text;
return thinking !== undefined && thinking !== "" ? [{ type: "thinking", text: thinking }] : [];
}
if (type === "toolCall") return [{ type: "toolCall", toolName: getString(part, "name") ?? "tool", summary: summarizeArgs(getProperty(part, "arguments")) }];
if (type === "image") return [{ type: "text", text: "[image]" }];
return objectFallback(part); return objectFallback(part);
}).map((part) => part.type === "text" && message?.role === "toolResult" }).map((part) => part.type === "text" && getString(message, "role") === "toolResult"
? { type: "toolResult", toolName: message.toolName ?? "tool", text: part.text, isError: !!message.isError } ? { type: "toolResult", toolName: getString(message, "toolName") ?? "tool", text: part.text, isError: getBoolean(message, "isError") === true }
: part); : part);
} }
function objectFallback(value: unknown): ChatPart[] { function objectFallback(value: unknown): ChatPart[] {
if (value == null) return []; if (value == null) return [];
if (typeof value === "object") return [{ type: "text", text: summarizeArgs(value) }]; if (typeof value === "object") return [{ type: "text", text: summarizeArgs(value) }];
return [{ type: "text", text: String(value) }]; return [{ type: "text", text: stringifyPrimitive(value) }];
} }
function summarizeArgs(args: any): string { function summarizeArgs(args: unknown): string {
if (!args || typeof args !== "object") return args == null ? "" : String(args); if (!isRecord(args)) return stringifyPrimitive(args);
if (typeof args.command === "string") return args.command; const command = getString(args, "command");
if (typeof args.path === "string") return args.path; if (command !== undefined) return command;
if (typeof args.oldText === "string" && typeof args.newText === "string") return "edit text replacement"; const path = getString(args, "path");
if (Array.isArray(args.edits)) return `${args.edits.length} edit${args.edits.length === 1 ? "" : "s"}`; if (path !== undefined) return path;
if (typeof args["oldText"] === "string" && typeof args["newText"] === "string") return "edit text replacement";
const edits = args["edits"];
if (Array.isArray(edits)) return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3); const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
return entries.map(([key, value]) => `${key}: ${shortValue(value)}`).join(" · "); return entries.map(([key, value]) => `${key}: ${shortValue(value)}`).join(" · ");
} }
@@ -81,7 +93,32 @@ function summarizeArgs(args: any): string {
function shortValue(value: unknown): string { function shortValue(value: unknown): string {
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}` : value; if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}` : value;
if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`; if (Array.isArray(value)) return `${String(value.length)} item${value.length === 1 ? "" : "s"}`;
if (typeof value === "object" && value) return "object"; if (typeof value === "object" && value !== null) return "object";
return "";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getProperty(value: unknown, key: string): unknown {
return isRecord(value) ? value[key] : undefined;
}
function getString(value: unknown, key: string): string | undefined {
const property = getProperty(value, key);
return typeof property === "string" ? property : undefined;
}
function getBoolean(value: unknown, key: string): boolean | undefined {
const property = getProperty(value, key);
return typeof property === "boolean" ? property : undefined;
}
function stringifyPrimitive(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
return ""; return "";
} }
@@ -8,7 +8,7 @@ export class AutocompleteMenu extends LitElement {
@property({ type: Number }) selectedIndex = 0; @property({ type: Number }) selectedIndex = 0;
@property({ attribute: false }) onPick?: (item: CompletionItem) => void; @property({ attribute: false }) onPick?: (item: CompletionItem) => void;
render() { override render() {
if (!this.items.length) return null; if (!this.items.length) return null;
return html` return html`
<div class="menu"> <div class="menu">
@@ -16,12 +16,12 @@ export class AutocompleteMenu extends LitElement {
<button class=${index === this.selectedIndex ? "selected" : ""} @mousedown=${(event: MouseEvent) => { event.preventDefault(); this.onPick?.(item); }}> <button class=${index === this.selectedIndex ? "selected" : ""} @mousedown=${(event: MouseEvent) => { event.preventDefault(); this.onPick?.(item); }}>
<strong>${item.insertText}</strong> <strong>${item.insertText}</strong>
<span>${item.detail}</span> <span>${item.detail}</span>
${item.description ? html`<small>${item.description}</small>` : null} ${item.description !== undefined && item.description !== "" ? html`<small>${item.description}</small>` : null}
</button> </button>
`)} `)}
</div> </div>
`; `;
} }
static styles = autocompleteStyles; static override styles = autocompleteStyles;
} }
+85 -23
View File
@@ -5,41 +5,92 @@ import type { ChatLine, ChatPart } from "./shared";
import { chatStyles } from "./shared"; import { chatStyles } from "./shared";
import "./FormattedText"; import "./FormattedText";
function isScrollPosition(value: unknown): value is { index: number; offset: number } {
return typeof value === "object"
&& value !== null
&& "index" in value
&& "offset" in value
&& typeof value.index === "number"
&& typeof value.offset === "number";
}
@customElement("chat-view") @customElement("chat-view")
export class ChatView extends LitElement { export class ChatView extends LitElement {
@property({ attribute: false }) messages: ChatLine[] = []; @property({ attribute: false }) messages: ChatLine[] = [];
@property() sessionId = ""; @property() sessionId = "";
@property({ type: Number }) messageStart = 0;
@property({ type: Number }) messageTotal = 0;
@property({ type: Boolean }) hasMore = false;
@property({ type: Boolean }) loadingMore = false;
@property({ attribute: false }) onLoadMore?: () => void;
@query(".chat") private chat?: HTMLDivElement; @query(".chat") private chat?: HTMLDivElement;
@state() private pinnedToBottom = true; @state() private pinnedToBottom = true;
@state() private openGroupKeys = new Set<string>(); @state() private openGroupKeys = new Set<string>();
@state() private loadedScrollPercent = 100;
private suppressScrollSave = false; private suppressScrollSave = false;
private saveScrollTimer?: number; private saveScrollTimer?: number;
disconnectedCallback(): void { override disconnectedCallback(): void {
window.clearTimeout(this.saveScrollTimer); window.clearTimeout(this.saveScrollTimer);
super.disconnectedCallback(); super.disconnectedCallback();
} }
protected willUpdate(changed: Map<string, unknown>): void { protected override willUpdate(changed: Map<string, unknown>): void {
if (changed.has("sessionId")) this.openGroupKeys = this.readOpenGroupKeys(); if (changed.has("sessionId")) this.openGroupKeys = this.readOpenGroupKeys();
this.pinnedToBottom = this.isNearBottom(); this.pinnedToBottom = this.isNearBottom();
} }
protected updated(changed: Map<string, unknown>): void { protected override updated(changed: Map<string, unknown>): void {
if (changed.has("sessionId")) return; if (changed.has("sessionId")) return;
if (changed.has("messages") && this.pinnedToBottom) this.scrollToBottom(); if (changed.has("messages") && this.pinnedToBottom) this.scrollToBottom();
this.updateLoadedScrollPercent();
} }
render() { override render() {
return html` return html`
<div class="chat" @scroll=${this.onScroll}> <div class="chat-wrap">
${groupChatMessages(this.messages).map((group) => group.kind === "message" ${this.renderHistoryIndicator()}
? this.renderMessage(group.message, group.index) <div class="chat" @scroll=${() => { this.onScroll(); }}>
: this.renderMessageGroup(group.messages, group.startIndex))} ${this.renderHistoryBoundary()}
${groupChatMessages(this.messages, this.messageStart).map((group) => group.kind === "message"
? this.renderMessage(group.message, group.index)
: this.renderMessageGroup(group.messages, group.startIndex))}
</div>
</div> </div>
`; `;
} }
private renderHistoryIndicator() {
if (!this.messages.length || this.messageTotal <= 0) return null;
const loadedCount = this.messages.length;
const loadedPercent = Math.min(100, Math.round((loadedCount / this.messageTotal) * 100));
const olderCount = this.messageStart;
const fullHistory = olderCount <= 0
? "full history loaded"
: `${olderCount} older not loaded · ${loadedPercent}% loaded`;
return html`
<div class="history-indicator">
<div>${fullHistory}</div>
<div>loaded scroll: ${this.loadedScrollPercent}% from top</div>
</div>
`;
}
private renderHistoryBoundary() {
const range = this.historyRangeLabel();
if (this.loadingMore) return html`<div class="history-boundary"><span>Loading earlier messages…</span>${range}</div>`;
if (this.hasMore) return html`<div class="history-boundary"><span>Scroll up to load earlier messages</span>${range}</div>`;
if (this.messages.length) return html`<div class="history-boundary"><span>Beginning of session</span>${range}</div>`;
return null;
}
private historyRangeLabel() {
if (!this.messages.length || this.messageTotal <= 0) return null;
const from = this.messageStart + 1;
const to = this.messageStart + this.messages.length;
return html`<small>Showing messages ${from}${to} of ${this.messageTotal}</small>`;
}
private renderMessage(message: ChatLine, index: number) { private renderMessage(message: ChatLine, index: number) {
return html` return html`
<article class="msg ${message.role}" data-index=${index}> <article class="msg ${message.role}" data-index=${index}>
@@ -52,7 +103,7 @@ export class ChatView extends LitElement {
private renderMessageGroup(messages: ChatLine[], startIndex: number) { private renderMessageGroup(messages: ChatLine[], startIndex: number) {
const key = this.groupKey(startIndex); const key = this.groupKey(startIndex);
return html` return html`
<details class="msg event-group" data-index=${startIndex} ?open=${this.openGroupKeys.has(key)} @toggle=${(event: Event) => this.onGroupToggle(key, event)}> <details class="msg event-group" data-index=${startIndex} ?open=${this.openGroupKeys.has(key)} @toggle=${(event: Event) => { this.onGroupToggle(key, event); }}>
<summary> <summary>
<b class="label">events</b> <b class="label">events</b>
<span>${summarizeChatGroup(messages)}</span> <span>${summarizeChatGroup(messages)}</span>
@@ -84,7 +135,8 @@ export class ChatView extends LitElement {
} }
private onGroupToggle(key: string, event: Event) { private onGroupToggle(key: string, event: Event) {
const details = event.currentTarget as HTMLDetailsElement; const details = event.currentTarget;
if (!(details instanceof HTMLDetailsElement)) return;
const openGroupKeys = new Set(this.openGroupKeys); const openGroupKeys = new Set(this.openGroupKeys);
if (details.open) openGroupKeys.add(key); if (details.open) openGroupKeys.add(key);
else openGroupKeys.delete(key); else openGroupKeys.delete(key);
@@ -93,10 +145,20 @@ export class ChatView extends LitElement {
} }
private onScroll() { private onScroll() {
this.updateLoadedScrollPercent();
if (this.chat && this.chat.scrollTop < 64 && this.hasMore && !this.loadingMore) this.onLoadMore?.();
this.pinnedToBottom = this.isNearBottom(); this.pinnedToBottom = this.isNearBottom();
if (!this.suppressScrollSave) this.scheduleScrollPositionSave(); if (!this.suppressScrollSave) this.scheduleScrollPositionSave();
} }
private updateLoadedScrollPercent(): void {
const chat = this.chat;
if (!chat) return;
const maxScroll = chat.scrollHeight - chat.clientHeight;
const percent = maxScroll <= 0 ? 100 : Math.round((chat.scrollTop / maxScroll) * 100);
this.loadedScrollPercent = Math.max(0, Math.min(100, percent));
}
private isNearBottom(): boolean { private isNearBottom(): boolean {
const chat = this.chat; const chat = this.chat;
if (!chat) return true; if (!chat) return true;
@@ -154,7 +216,7 @@ export class ChatView extends LitElement {
} }
const chatTop = chat.getBoundingClientRect().top; const chatTop = chat.getBoundingClientRect().top;
const position = { const position = {
index: Number(firstVisible.dataset.index ?? 0), index: Number(firstVisible.dataset["index"] ?? 0),
offset: firstVisible.getBoundingClientRect().top - chatTop, offset: firstVisible.getBoundingClientRect().top - chatTop,
}; };
localStorage.setItem(this.storageKey(sessionId), JSON.stringify(position)); localStorage.setItem(this.storageKey(sessionId), JSON.stringify(position));
@@ -165,17 +227,17 @@ export class ChatView extends LitElement {
private scheduleScrollPositionSave() { private scheduleScrollPositionSave() {
window.clearTimeout(this.saveScrollTimer); window.clearTimeout(this.saveScrollTimer);
this.saveScrollTimer = window.setTimeout(() => this.saveScrollPosition(), 180); this.saveScrollTimer = window.setTimeout(() => { this.saveScrollPosition(); }, 180);
} }
private readStoredScrollPosition(): { index: number; offset: number } | undefined { private readStoredScrollPosition(): { index: number; offset: number } | undefined {
if (!this.sessionId) return undefined; if (this.sessionId === "") return undefined;
try { try {
const raw = localStorage.getItem(this.storageKey()); const raw = localStorage.getItem(this.storageKey());
if (!raw) return undefined; if (raw === null || raw === "") return undefined;
const value = JSON.parse(raw); const value: unknown = JSON.parse(raw);
if (typeof value?.index !== "number" || typeof value?.offset !== "number") return undefined; if (!isScrollPosition(value)) return undefined;
return { index: value.index, offset: value.offset }; return value;
} catch { } catch {
return undefined; return undefined;
} }
@@ -192,7 +254,7 @@ export class ChatView extends LitElement {
} }
private articleAt(index: number): HTMLElement | undefined { private articleAt(index: number): HTMLElement | undefined {
return this.articles().find((article) => Number(article.dataset.index) === index); return this.articles().find((article) => Number(article.dataset["index"]) === index);
} }
private articles(): HTMLElement[] { private articles(): HTMLElement[] {
@@ -218,14 +280,14 @@ export class ChatView extends LitElement {
} }
private groupKey(startIndex: number): string { private groupKey(startIndex: number): string {
return `${this.sessionId}:${startIndex}`; return `${this.sessionId}:${String(startIndex)}`;
} }
private readOpenGroupKeys(): Set<string> { private readOpenGroupKeys(): Set<string> {
if (!this.sessionId) return new Set(); if (this.sessionId === "") return new Set();
try { try {
const raw = localStorage.getItem(this.groupStorageKey()); const raw = localStorage.getItem(this.groupStorageKey());
const value = raw ? JSON.parse(raw) : []; const value: unknown = raw !== null && raw !== "" ? JSON.parse(raw) : [];
return new Set(Array.isArray(value) ? value.filter((item) => typeof item === "string") : []); return new Set(Array.isArray(value) ? value.filter((item) => typeof item === "string") : []);
} catch { } catch {
return new Set(); return new Set();
@@ -233,7 +295,7 @@ export class ChatView extends LitElement {
} }
private saveOpenGroupKeys(): void { private saveOpenGroupKeys(): void {
if (!this.sessionId) return; if (this.sessionId === "") return;
try { try {
localStorage.setItem(this.groupStorageKey(), JSON.stringify([...this.openGroupKeys])); localStorage.setItem(this.groupStorageKey(), JSON.stringify([...this.openGroupKeys]));
} catch { } catch {
@@ -241,5 +303,5 @@ export class ChatView extends LitElement {
} }
} }
static styles = chatStyles; static override styles = chatStyles;
} }
+7 -7
View File
@@ -5,25 +5,25 @@ import { commandPickerStyles } from "./shared";
@customElement("command-picker") @customElement("command-picker")
export class CommandPicker extends LitElement { export class CommandPicker extends LitElement {
@property() title = "Select"; @property() override title = "Select";
@property({ attribute: false }) options: CommandOption[] = []; @property({ attribute: false }) options: CommandOption[] = [];
@property({ attribute: false }) onPick?: (value: string) => void; @property({ attribute: false }) onPick?: (value: string) => void;
@property({ attribute: false }) onCancel?: () => void; @property({ attribute: false }) onCancel?: () => void;
@state() private selectedIndex = 0; @state() private selectedIndex = 0;
render() { override render() {
return html` return html`
<div class="backdrop" @mousedown=${() => this.onCancel?.()}> <div class="backdrop" @mousedown=${() => this.onCancel?.()}>
<section @mousedown=${(event: MouseEvent) => event.stopPropagation()}> <section @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }}>
<header> <header>
<strong>${this.title}</strong> <strong>${this.title}</strong>
<button @click=${() => this.onCancel?.()}>×</button> <button @click=${() => this.onCancel?.()}>×</button>
</header> </header>
<div class="options" @keydown=${(event: KeyboardEvent) => this.handleKeyDown(event)} tabindex="0"> <div class="options" @keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }} tabindex="0">
${this.options.map((option, index) => html` ${this.options.map((option, index) => html`
<button class=${index === this.selectedIndex ? "selected" : ""} @click=${() => this.onPick?.(option.value)}> <button class=${index === this.selectedIndex ? "selected" : ""} @click=${() => this.onPick?.(option.value)}>
<span>${option.label}</span> <span>${option.label}</span>
${option.description ? html`<small>${option.description}</small>` : null} ${option.description !== undefined && option.description !== "" ? html`<small>${option.description}</small>` : null}
</button> </button>
`)} `)}
</div> </div>
@@ -32,7 +32,7 @@ export class CommandPicker extends LitElement {
`; `;
} }
firstUpdated() { override firstUpdated() {
this.renderRoot.querySelector<HTMLElement>(".options")?.focus(); this.renderRoot.querySelector<HTMLElement>(".options")?.focus();
} }
@@ -53,5 +53,5 @@ export class CommandPicker extends LitElement {
} }
} }
static styles = commandPickerStyles; static override styles = commandPickerStyles;
} }
+7 -5
View File
@@ -9,13 +9,15 @@ export class Composer extends LitElement {
@property({ attribute: false }) onStopSession?: () => void; @property({ attribute: false }) onStopSession?: () => void;
@state() private draft = ""; @state() private draft = "";
render() { override render() {
return html` return html`
<footer> <footer>
<textarea <textarea
.value=${this.draft} .value=${this.draft}
?disabled=${this.disabled} ?disabled=${this.disabled}
@input=${(e: Event) => (this.draft = (e.target as HTMLTextAreaElement).value)} @input=${(event: Event) => {
if (event.target instanceof HTMLTextAreaElement) this.draft = event.target.value;
}}
@keydown=${(e: KeyboardEvent) => { @keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -24,7 +26,7 @@ export class Composer extends LitElement {
}} }}
placeholder="Message pi..." placeholder="Message pi..."
></textarea> ></textarea>
<button ?disabled=${this.disabled} @click=${this.send}>Send</button> <button ?disabled=${this.disabled} @click=${() => { this.send(); }}>Send</button>
<button ?disabled=${this.disabled} @click=${() => this.onStopSession?.()}>Stop session</button> <button ?disabled=${this.disabled} @click=${() => this.onStopSession?.()}>Stop session</button>
</footer> </footer>
`; `;
@@ -32,10 +34,10 @@ export class Composer extends LitElement {
private send() { private send() {
const text = this.draft.trim(); const text = this.draft.trim();
if (!text || this.disabled) return; if (text === "" || this.disabled) return;
this.draft = ""; this.draft = "";
this.onSend?.(text); this.onSend?.(text);
} }
static styles = composerStyles; static override styles = composerStyles;
} }
+2 -2
View File
@@ -8,9 +8,9 @@ import { formattedTextStyles } from "./shared";
export class FormattedText extends LitElement { export class FormattedText extends LitElement {
@property() text = ""; @property() text = "";
render() { override render() {
return html`<div class="formatted">${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`; return html`<div class="formatted">${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`;
} }
static styles = formattedTextStyles; static override styles = formattedTextStyles;
} }
+13 -13
View File
@@ -25,30 +25,30 @@ export class PiWebApp extends LitElement {
private readonly sessions = new SessionController( private readonly sessions = new SessionController(
() => this.state, () => this.state,
(patch) => this.setState(patch), (patch) => { this.setState(patch); },
() => this.updateUrl(), () => { this.updateUrl(); },
); );
private readonly workspaces = new WorkspaceController( private readonly workspaces = new WorkspaceController(
() => this.state, () => this.state,
(patch) => this.setState(patch), (patch) => { this.setState(patch); },
() => this.updateUrl(), () => { this.updateUrl(); },
this.sessions, this.sessions,
); );
private readonly projects = new ProjectController( private readonly projects = new ProjectController(
() => this.state, () => this.state,
(patch) => this.setState(patch), (patch) => { this.setState(patch); },
this.workspaces, this.workspaces,
); );
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
connectedCallback(): void { override connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
window.addEventListener("popstate", this.onPopState); window.addEventListener("popstate", this.onPopState);
this.sessions.connectStatusUpdates(); this.sessions.connectStatusUpdates();
void this.loadProjectsAndRestoreRoute(); void this.loadProjectsAndRestoreRoute();
} }
disconnectedCallback(): void { override disconnectedCallback(): void {
window.removeEventListener("popstate", this.onPopState); window.removeEventListener("popstate", this.onPopState);
this.sessions.dispose(); this.sessions.dispose();
super.disconnectedCallback(); super.disconnectedCallback();
@@ -65,7 +65,7 @@ export class PiWebApp extends LitElement {
private async restoreRoute(updateUrl: boolean) { private async restoreRoute(updateUrl: boolean) {
const route = readRoute(); const route = readRoute();
if (!route.projectId) return; if (route.projectId === undefined || route.projectId === "") return;
const project = this.state.projects.find((p) => p.id === route.projectId); const project = this.state.projects.find((p) => p.id === route.projectId);
if (!project) return; if (!project) return;
await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl });
@@ -89,7 +89,7 @@ export class PiWebApp extends LitElement {
}); });
} }
render() { override render() {
const state = this.state; const state = this.state;
return html` return html`
<div class="shell"> <div class="shell">
@@ -106,18 +106,18 @@ export class PiWebApp extends LitElement {
${state.error ? html`<div class="error">${state.error}</div>` : null} ${state.error ? html`<div class="error">${state.error}</div>` : null}
${state.selectedSession ? html` ${state.selectedSession ? html`
<status-bar .status=${state.status} .activity=${state.activity} .workspace=${state.selectedWorkspace}></status-bar> <status-bar .status=${state.status} .activity=${state.activity} .workspace=${state.selectedWorkspace}></status-bar>
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages}></chat-view> <chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .onLoadMore=${() => this.withChatScrollTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .onSend=${(text: string) => this.sessions.send(text)} .onStopSession=${() => this.sessions.stopSession()}></prompt-editor> <prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .onSend=${(text: string) => this.sessions.send(text)} .onStopSession=${() => this.sessions.stopSession()}></prompt-editor>
${state.commandDialog ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog!.requestId, value)} .onCancel=${() => this.sessions.cancelCommand()}></command-picker>` : null} ${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
` : html`<div class="empty">Select or start a session.</div>`} ` : html`<div class="empty">Select or start a session.</div>`}
</main> </main>
</div> </div>
`; `;
} }
static styles = appStyles; static override styles = appStyles;
} }
function nextFrame(): Promise<void> { function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve())); return new Promise((resolve) => requestAnimationFrame(() => { resolve(); }));
} }
+2 -2
View File
@@ -9,7 +9,7 @@ export class ProjectList extends LitElement {
@property({ attribute: false }) selected?: Project; @property({ attribute: false }) selected?: Project;
@property({ attribute: false }) onSelect?: (project: Project) => void; @property({ attribute: false }) onSelect?: (project: Project) => void;
render() { override render() {
return html` return html`
<section> <section>
<h2>Projects</h2> <h2>Projects</h2>
@@ -22,5 +22,5 @@ export class ProjectList extends LitElement {
`; `;
} }
static styles = listStyles; static override styles = listStyles;
} }
+41 -23
View File
@@ -18,20 +18,20 @@ export class PromptEditor extends LitElement {
@state() private selectedIndex = 0; @state() private selectedIndex = 0;
private requestVersion = 0; private requestVersion = 0;
protected willUpdate(changed: PropertyValues<this>) { protected override willUpdate(changed: PropertyValues<this>) {
if (!changed.has("sessionId")) return; if (!changed.has("sessionId")) return;
const previousSessionId = changed.get("sessionId") as string | undefined; const previousSessionId = changed.get("sessionId");
if (previousSessionId) saveDraft(previousSessionId, this.draft); if (previousSessionId !== undefined && previousSessionId !== "") saveDraft(previousSessionId, this.draft);
this.draft = this.sessionId ? loadDraft(this.sessionId) : ""; this.draft = this.sessionId !== undefined && this.sessionId !== "" ? loadDraft(this.sessionId) : "";
this.completions = []; this.completions = [];
this.selectedIndex = 0; this.selectedIndex = 0;
} }
protected updated(changed: PropertyValues) { protected override updated(changed: PropertyValues) {
if (changed.has("draft") || changed.has("sessionId")) this.resizeTextarea(); if (changed.has("draft") || changed.has("sessionId")) this.resizeTextarea();
} }
render() { override render() {
const inputMode = inputModeForDraft(this.draft); const inputMode = inputModeForDraft(this.draft);
const shellMode = inputMode.kind === "shell"; const shellMode = inputMode.kind === "shell";
return html` return html`
@@ -40,14 +40,16 @@ export class PromptEditor extends LitElement {
<textarea <textarea
.value=${this.draft} .value=${this.draft}
?disabled=${this.disabled} ?disabled=${this.disabled}
@input=${(event: Event) => this.updateDraft((event.target as HTMLTextAreaElement).value)} @input=${(event: Event) => {
@keydown=${(event: KeyboardEvent) => this.handleKeyDown(event)} if (event.target instanceof HTMLTextAreaElement) this.updateDraft(event.target.value);
}}
@keydown=${(event: KeyboardEvent) => { this.handleKeyDown(event); }}
placeholder="Message pi... Use / for commands, @ for files" placeholder="Message pi... Use / for commands, @ for files"
></textarea> ></textarea>
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null} ${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => this.pick(item)}></autocomplete-menu> <autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
</div> </div>
<button ?disabled=${this.disabled} @click=${this.send}>Send</button> <button ?disabled=${this.disabled} @click=${() => { this.send(); }}>Send</button>
<button ?disabled=${this.disabled} title="Stop only this Pi session from continuing" @click=${() => this.onStopSession?.()}>Stop session</button> <button ?disabled=${this.disabled} title="Stop only this Pi session from continuing" @click=${() => this.onStopSession?.()}>Stop session</button>
</footer> </footer>
`; `;
@@ -61,12 +63,12 @@ export class PromptEditor extends LitElement {
const textarea = this.textarea; const textarea = this.textarea;
if (!textarea) return; if (!textarea) return;
textarea.style.height = "auto"; textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`; textarea.style.height = `${String(textarea.scrollHeight)}px`;
} }
private updateDraft(value: string) { private updateDraft(value: string) {
this.draft = value; this.draft = value;
if (this.sessionId) saveDraft(this.sessionId, this.draft); if (this.sessionId !== undefined && this.sessionId !== "") saveDraft(this.sessionId, this.draft);
void this.refreshCompletions(); void this.refreshCompletions();
} }
@@ -74,19 +76,26 @@ export class PromptEditor extends LitElement {
const trigger = this.currentTrigger(); const trigger = this.currentTrigger();
const version = ++this.requestVersion; const version = ++this.requestVersion;
this.selectedIndex = 0; this.selectedIndex = 0;
if (!trigger) { if (trigger === undefined) {
this.completions = []; this.completions = [];
return; return;
} }
if (trigger.kind === "command" && this.sessionId) { if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") {
const commands = await api.commands(this.sessionId).catch(() => [] as SlashCommand[]); const commands = await api.commands(this.sessionId).catch(emptySlashCommands);
if (version !== this.requestVersion) return; if (version !== this.requestVersion) return;
this.completions = commands this.completions = commands
.filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase())) .filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase()))
.slice(0, 12) .slice(0, 12)
.map((command) => ({ kind: "command", replaceFrom: trigger.from, replaceTo: this.draft.length, insertText: `/${command.name}`, detail: command.source, description: command.description })); .map((command) => ({
} else if (trigger.kind === "file" && this.cwd) { kind: "command",
const files = await api.files(this.cwd, trigger.query, trigger.fileKind).catch(() => [] as FileSuggestion[]); replaceFrom: trigger.from,
replaceTo: this.draft.length,
insertText: `/${command.name}`,
detail: command.source,
...(command.description === undefined ? {} : { description: command.description }),
}));
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
const files = await api.files(this.cwd, trigger.query, trigger.fileKind).catch(emptyFileSuggestions);
if (version !== this.requestVersion) return; if (version !== this.requestVersion) return;
this.completions = files this.completions = files
.slice(0, 12) .slice(0, 12)
@@ -119,7 +128,8 @@ export class PromptEditor extends LitElement {
} }
if (event.key === "Tab" || event.key === "Enter") { if (event.key === "Tab" || event.key === "Enter") {
event.preventDefault(); event.preventDefault();
this.pick(this.completions[this.selectedIndex]); const completion = this.completions[this.selectedIndex];
if (completion !== undefined) this.pick(completion);
return; return;
} }
if (event.key === "Escape") { if (event.key === "Escape") {
@@ -136,20 +146,28 @@ export class PromptEditor extends LitElement {
private pick(item: CompletionItem) { private pick(item: CompletionItem) {
this.draft = `${this.draft.slice(0, item.replaceFrom)}${item.insertText} ${this.draft.slice(item.replaceTo)}`; this.draft = `${this.draft.slice(0, item.replaceFrom)}${item.insertText} ${this.draft.slice(item.replaceTo)}`;
if (this.sessionId) saveDraft(this.sessionId, this.draft); if (this.sessionId !== undefined && this.sessionId !== "") saveDraft(this.sessionId, this.draft);
this.completions = []; this.completions = [];
} }
private send() { private send() {
const text = this.draft.trim(); const text = this.draft.trim();
if (!text || this.disabled) return; if (text === "" || this.disabled) return;
this.draft = ""; this.draft = "";
if (this.sessionId) clearDraft(this.sessionId); if (this.sessionId !== undefined && this.sessionId !== "") clearDraft(this.sessionId);
this.completions = []; this.completions = [];
this.onSend?.(text); this.onSend?.(text);
} }
static styles = promptEditorStyles; static override styles = promptEditorStyles;
}
function emptySlashCommands(): SlashCommand[] {
return [];
}
function emptyFileSuggestions(): FileSuggestion[] {
return [];
} }
const draftStoragePrefix = "pi-web:prompt-draft:"; const draftStoragePrefix = "pi-web:prompt-draft:";
+10 -5
View File
@@ -3,6 +3,11 @@ import { customElement, property } from "lit/decorators.js";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api"; import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { listStyles } from "./shared"; import { listStyles } from "./shared";
function sessionLabel(session: SessionInfo): string {
if (session.name !== undefined && session.name !== "") return session.name;
return session.firstMessage !== "" ? session.firstMessage : session.id.slice(0, 8);
}
@customElement("session-list") @customElement("session-list")
export class SessionList extends LitElement { export class SessionList extends LitElement {
@property({ attribute: false }) sessions: SessionInfo[] = []; @property({ attribute: false }) sessions: SessionInfo[] = [];
@@ -13,13 +18,13 @@ export class SessionList extends LitElement {
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
@property({ attribute: false }) onStart?: () => void; @property({ attribute: false }) onStart?: () => void;
render() { override render() {
return html` return html`
<section> <section>
<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2> <h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>
${this.sessions.map((session) => html` ${this.sessions.map((session) => html`
<button class=${this.selected?.id === session.id ? "selected" : ""} @click=${() => this.onSelect?.(session)}> <button class=${this.selected?.id === session.id ? "selected" : ""} @click=${() => this.onSelect?.(session)}>
<span>${session.name || session.firstMessage || session.id.slice(0, 8)}</span><small>${this.renderStatus(session)}${session.messageCount} messages</small> <span>${sessionLabel(session)}</span><small>${this.renderStatus(session)}${String(session.messageCount)} messages</small>
</button> </button>
`)} `)}
</section> </section>
@@ -30,13 +35,13 @@ export class SessionList extends LitElement {
const status = this.statuses[session.id]; const status = this.statuses[session.id];
const activity = this.activities[session.id]; const activity = this.activities[session.id];
if (activity?.phase === "active") return `${activity.label} · `; if (activity?.phase === "active") return `${activity.label} · `;
if (!status) return ""; if (status === undefined) return "";
if (status.isStreaming) return "● streaming · "; if (status.isStreaming) return "● streaming · ";
if (status.isBashRunning) return "● bash · "; if (status.isBashRunning) return "● bash · ";
if (status.isCompacting) return "● compacting · "; if (status.isCompacting) return "● compacting · ";
if (status.pendingMessageCount) return `${status.pendingMessageCount} pending · `; if (status.pendingMessageCount > 0) return `${String(status.pendingMessageCount)} pending · `;
return ""; return "";
} }
static styles = listStyles; static override styles = listStyles;
} }
+8 -8
View File
@@ -10,12 +10,12 @@ export class StatusBar extends LitElement {
@property({ attribute: false }) activity?: SessionActivity; @property({ attribute: false }) activity?: SessionActivity;
@property({ attribute: false }) workspace?: Workspace; @property({ attribute: false }) workspace?: Workspace;
render() { override render() {
const status = this.status; const status = this.status;
if (!status) return html`<div class="bar muted">No session status yet</div>`; if (status === undefined) return html`<div class="bar muted">No session status yet</div>`;
const model = status.model?.id ?? "no model"; const model = status.model?.id ?? "no model";
const provider = status.model?.provider ? `${status.model.provider}/` : ""; const provider = status.model?.provider !== undefined && status.model.provider !== "" ? `${status.model.provider}/` : "";
const state = status.isCompacting ? "compacting" : status.isBashRunning ? "bash" : status.isStreaming ? "running" : status.pendingMessageCount ? "queued" : "idle"; const state = status.isCompacting ? "compacting" : status.isBashRunning ? "bash" : status.isStreaming ? "running" : status.pendingMessageCount > 0 ? "queued" : "idle";
const active = state !== "idle" || this.activity?.phase === "active"; const active = state !== "idle" || this.activity?.phase === "active";
const context = status.contextUsage; const context = status.contextUsage;
const contextText = context const contextText = context
@@ -34,17 +34,17 @@ export class StatusBar extends LitElement {
<span>↓${formatTokenCount(tokens.output)}</span> <span>↓${formatTokenCount(tokens.output)}</span>
<span>${contextText}</span> <span>${contextText}</span>
<span>${formatCost(status.cost)}</span> <span>${formatCost(status.cost)}</span>
${status.pendingMessageCount ? html`<span>${status.pendingMessageCount} queued</span>` : null} ${status.pendingMessageCount > 0 ? html`<span>${String(status.pendingMessageCount)} queued</span>` : null}
</div> </div>
`; `;
} }
private activityText(state: string): string { private activityText(state: string): string {
const activity = this.activity; const activity = this.activity;
if (!activity) return state; if (activity === undefined) return state;
if (state !== "idle" && activity.phase === "idle") return state; if (state !== "idle" && activity.phase === "idle") return state;
return activity.detail ? `${activity.label}: ${activity.detail}` : activity.label; return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label;
} }
static styles = statusBarStyles; static override styles = statusBarStyles;
} }
+2 -2
View File
@@ -9,7 +9,7 @@ export class WorkspaceList extends LitElement {
@property({ attribute: false }) selected?: Workspace; @property({ attribute: false }) selected?: Workspace;
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void; @property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
render() { override render() {
return html` return html`
<section> <section>
<h2>Workspaces</h2> <h2>Workspaces</h2>
@@ -22,5 +22,5 @@ export class WorkspaceList extends LitElement {
`; `;
} }
static styles = listStyles; static override styles = listStyles;
} }
+4
View File
@@ -50,7 +50,9 @@ export const listStyles = css`
export const chatStyles = css` export const chatStyles = css`
:host { display: block; min-height: 0; color: #e6edf3; font: 14px system-ui, sans-serif; } :host { display: block; min-height: 0; color: #e6edf3; font: 14px system-ui, sans-serif; }
.chat-wrap { position: relative; height: 100%; min-height: 0; }
.chat { height: 100%; overflow: auto; padding: 16px; box-sizing: border-box; } .chat { height: 100%; overflow: auto; padding: 16px; box-sizing: border-box; }
.history-indicator { position: absolute; top: 10px; right: 18px; z-index: 2; display: grid; gap: 2px; max-width: min(320px, calc(100% - 36px)); border: 1px solid #30363d; border-radius: 8px; background: #0d1117dd; color: #8b949e; padding: 6px 8px; font-size: 12px; text-align: right; pointer-events: none; box-shadow: 0 8px 24px #0006; }
.msg { margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; } .msg { margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; }
.msg.user { border-color: #2f81f7; background: #0d2847; } .msg.user { border-color: #2f81f7; background: #0d2847; }
.msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; } .msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; }
@@ -64,6 +66,8 @@ export const chatStyles = css`
.group-msg.tool { color: #d29922; } .group-msg.tool { color: #d29922; }
.group-msg.system { color: #ff7b72; } .group-msg.system { color: #ff7b72; }
.group-msg.bash { color: #3fb950; } .group-msg.bash { color: #3fb950; }
.history-boundary { display: grid; gap: 3px; margin: 0 0 14px; color: #8b949e; font-size: 12px; text-align: center; }
.history-boundary small { color: #6e7681; }
.label { display: block; margin-bottom: 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; } .label { display: block; margin-bottom: 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; }
formatted-text.part { display: block; } formatted-text.part { display: block; }
.part + .part { margin-top: 10px; } .part + .part { margin-top: 10px; }
@@ -16,7 +16,7 @@ export class ProjectController {
async addProject() { async addProject() {
const path = prompt("Project folder path"); const path = prompt("Project folder path");
if (!path) return; if (path === null || path === "") return;
try { try {
const project = await api.addProject(path); const project = await api.addProject(path);
const projects = this.getState().projects; const projects = this.getState().projects;
@@ -1,4 +1,6 @@
import { api, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus } from "../api"; import { api, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus } from "../api";
const MESSAGE_PAGE_SIZE = 100;
import { normalizeMessages, textMessage } from "../chatMessages"; import { normalizeMessages, textMessage } from "../chatMessages";
import { applyTranscriptEvent } from "../chatTranscript"; import { applyTranscriptEvent } from "../chatTranscript";
import { isShellInput } from "../inputModes"; import { isShellInput } from "../inputModes";
@@ -25,7 +27,7 @@ export class SessionController {
clearActiveSession() { clearActiveSession() {
this.socket.close(); this.socket.close();
this.setState({ selectedSession: undefined, messages: [], status: undefined, activity: undefined }); this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined });
} }
async startSession() { async startSession() {
@@ -40,22 +42,42 @@ export class SessionController {
} }
} }
async selectSession(session: SessionInfo, options?: { updateUrl?: boolean }) { async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) {
this.socket.close(); this.socket.close();
try { try {
const buffered: SessionUiEvent[] = []; const buffered: SessionUiEvent[] = [];
this.socket.connect(session.id, (event) => buffered.push(event)); this.socket.connect(session.id, (event) => buffered.push(event));
const [messages, status] = await Promise.all([api.messages(session.id), api.status(session.id)]); const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]);
this.setState({ selectedSession: session, messages: normalizeMessages(messages), status }); this.setState({ selectedSession: session, messages: normalizeMessages(page.messages), messagePageStart: page.start, messagePageTotal: page.total, isLoadingEarlierMessages: false, status });
this.applyStatus(status); this.applyStatus(status);
for (const event of buffered) this.applyEvent(event); for (const event of buffered) this.applyEvent(event);
this.socket.setHandler((event) => this.applyEvent(event)); this.socket.setHandler((event) => { this.applyEvent(event); });
if (options?.updateUrl !== false) this.updateUrl(); if (options?.updateUrl !== false) this.updateUrl();
} catch (error) { } catch (error) {
this.setState({ error: String(error) }); this.setState({ error: String(error) });
} }
} }
async loadEarlierMessages() {
const state = this.getState();
const session = state.selectedSession;
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
this.setState({ isLoadingEarlierMessages: true });
try {
const page = await api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE });
if (this.getState().selectedSession?.id !== session.id) return;
this.setState({
messages: [...normalizeMessages(page.messages), ...this.getState().messages],
messagePageStart: page.start,
messagePageTotal: page.total,
});
} catch (error) {
this.setState({ error: String(error) });
} finally {
if (this.getState().selectedSession?.id === session.id) this.setState({ isLoadingEarlierMessages: false });
}
}
async send(text: string) { async send(text: string) {
const trimmed = text.trim(); const trimmed = text.trim();
if (trimmed.startsWith("/")) return this.runCommand(text); if (trimmed.startsWith("/")) return this.runCommand(text);
@@ -126,7 +148,7 @@ export class SessionController {
return; return;
} }
const message = result.type === "unsupported" ? result.message : result.message; const message = result.type === "unsupported" ? result.message : result.message;
if (message) this.setState({ messages: [...this.getState().messages, textMessage(result.type === "unsupported" ? "system" : "tool", message)] }); if (message !== undefined && message !== "") this.setState({ messages: [...this.getState().messages, textMessage(result.type === "unsupported" ? "system" : "tool", message)] });
if (result.type === "done" && result.session) { if (result.type === "done" && result.session) {
const current = this.getState().selectedSession; const current = this.getState().selectedSession;
const sessions = [result.session, ...this.getState().sessions.filter((session) => session.id !== result.session?.id)]; const sessions = [result.session, ...this.getState().sessions.filter((session) => session.id !== result.session?.id)];
+3 -3
View File
@@ -5,7 +5,7 @@ export type SetState = (patch: Partial<AppState>) => void;
export type UpdateUrl = () => void; export type UpdateUrl = () => void;
export interface RouteTarget { export interface RouteTarget {
workspaceId?: string; workspaceId?: string | undefined;
sessionId?: string; sessionId?: string | undefined;
updateUrl?: boolean; updateUrl?: boolean | undefined;
} }
@@ -16,7 +16,7 @@ export class WorkspaceController {
try { try {
const workspaces = await api.workspaces(project.id); const workspaces = await api.workspaces(project.id);
this.setState({ workspaces }); this.setState({ workspaces });
const workspace = target?.workspaceId ? workspaces.find((w) => w.id === target.workspaceId) : workspaces[0]; const workspace = target?.workspaceId !== undefined && target.workspaceId !== "" ? workspaces.find((w) => w.id === target.workspaceId) : workspaces[0];
if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl }); if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl });
else if (target?.updateUrl !== false) this.updateUrl(); else if (target?.updateUrl !== false) this.updateUrl();
} catch (error) { } catch (error) {
@@ -24,14 +24,14 @@ export class WorkspaceController {
} }
} }
async selectWorkspace(workspace: Workspace, target?: { sessionId?: string; updateUrl?: boolean }) { async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) {
this.sessions.clearActiveSession(); this.sessions.clearActiveSession();
this.setState({ selectedWorkspace: workspace, sessions: [], error: "" }); this.setState({ selectedWorkspace: workspace, sessions: [], error: "" });
try { try {
const sessions = await api.sessions(workspace.path); const sessions = await api.sessions(workspace.path);
this.setState({ sessions }); this.setState({ sessions });
const sessionId = target?.sessionId; const sessionId = target?.sessionId;
const session = sessionId ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : sessions[0]; const session = sessionId !== undefined && sessionId !== "" ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : sessions[0];
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl }); if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
else if (target?.updateUrl !== false) this.updateUrl(); else if (target?.updateUrl !== false) this.updateUrl();
} catch (error) { } catch (error) {
+2 -2
View File
@@ -1,7 +1,7 @@
import { marked } from "marked"; import { marked } from "marked";
export function toSafeMarkdownHtml(text: string): string { export function toSafeMarkdownHtml(text: string): string {
const html = marked.parse(escapeHtml(text), { async: false, breaks: true, gfm: true }) as string; const html = marked.parse(escapeHtml(text), { async: false, breaks: true, gfm: true });
return sanitizeHtml(html); return sanitizeHtml(html);
} }
@@ -15,7 +15,7 @@ function escapeHtml(text: string): string {
function sanitizeHtml(html: string): string { function sanitizeHtml(html: string): string {
const template = document.createElement("template"); const template = document.createElement("template");
template.innerHTML = html; template.innerHTML = html;
template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => node.remove()); template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => { node.remove(); });
template.content.querySelectorAll("*").forEach((element) => { template.content.querySelectorAll("*").forEach((element) => {
for (const attribute of [...element.attributes]) { for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase(); const name = attribute.name.toLowerCase();
+6 -6
View File
@@ -1,7 +1,7 @@
export interface AppRoute { export interface AppRoute {
projectId?: string; projectId: string | undefined;
workspaceId?: string; workspaceId: string | undefined;
sessionId?: string; sessionId: string | undefined;
} }
export function readRoute(): AppRoute { export function readRoute(): AppRoute {
@@ -18,9 +18,9 @@ export function writeRoute(route: AppRoute): void {
url.searchParams.delete("project"); url.searchParams.delete("project");
url.searchParams.delete("workspace"); url.searchParams.delete("workspace");
url.searchParams.delete("session"); url.searchParams.delete("session");
if (route.projectId) url.searchParams.set("project", route.projectId); if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId);
if (route.workspaceId) url.searchParams.set("workspace", route.workspaceId); if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
if (route.sessionId) url.searchParams.set("session", route.sessionId); if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId);
const next = `${url.pathname}${url.search}${url.hash}`; const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next !== current) window.history.pushState({}, "", url); if (next !== current) window.history.pushState({}, "", url);
+23 -15
View File
@@ -13,9 +13,9 @@ export type SessionUiEvent =
| { type: "session.error"; message: string }; | { type: "session.error"; message: string };
export class SessionSocket { export class SessionSocket {
private socket?: WebSocket; private socket: WebSocket | undefined;
private sessionId?: string; private sessionId: string | undefined;
private onEvent?: (event: SessionUiEvent) => void; private onEvent: ((event: SessionUiEvent) => void) | undefined;
private reconnectTimer?: number; private reconnectTimer?: number;
private reconnectDelay = 500; private reconnectDelay = 500;
private shouldReconnect = false; private shouldReconnect = false;
@@ -42,14 +42,14 @@ export class SessionSocket {
} }
private open(): void { private open(): void {
if (!this.sessionId || !this.shouldReconnect) return; if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return;
const socket = sessionEvents(this.sessionId); const socket = sessionEvents(this.sessionId);
this.socket = socket; this.socket = socket;
socket.onopen = () => { socket.onopen = () => {
this.reconnectDelay = 500; this.reconnectDelay = 500;
}; };
socket.onmessage = (message) => void this.handleMessage(message.data); socket.onmessage = (message) => void this.handleMessage(message.data);
socket.onerror = () => socket.close(); socket.onerror = () => { socket.close(); };
socket.onclose = () => { socket.onclose = () => {
if (this.socket === socket) this.socket = undefined; if (this.socket === socket) this.socket = undefined;
this.scheduleReconnect(); this.scheduleReconnect();
@@ -61,7 +61,7 @@ export class SessionSocket {
window.clearTimeout(this.reconnectTimer); window.clearTimeout(this.reconnectTimer);
const delay = this.reconnectDelay; const delay = this.reconnectDelay;
this.reconnectDelay = Math.min(this.reconnectDelay * 1.6, 5000); this.reconnectDelay = Math.min(this.reconnectDelay * 1.6, 5000);
this.reconnectTimer = window.setTimeout(() => this.open(), delay); this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay);
} }
private async handleMessage(data: MessageEvent["data"]): Promise<void> { private async handleMessage(data: MessageEvent["data"]): Promise<void> {
@@ -71,8 +71,8 @@ export class SessionSocket {
} }
export class GlobalSessionSocket { export class GlobalSessionSocket {
private socket?: WebSocket; private socket: WebSocket | undefined;
private onEvent?: (event: Extract<SessionUiEvent, { type: "status.update" | "activity.update" }>) => void; private onEvent: ((event: Extract<SessionUiEvent, { type: "status.update" | "activity.update" }>) => void) | undefined;
private reconnectTimer?: number; private reconnectTimer?: number;
private reconnectDelay = 500; private reconnectDelay = 500;
private shouldReconnect = false; private shouldReconnect = false;
@@ -100,7 +100,7 @@ export class GlobalSessionSocket {
this.reconnectDelay = 500; this.reconnectDelay = 500;
}; };
socket.onmessage = (message) => void this.handleMessage(message.data); socket.onmessage = (message) => void this.handleMessage(message.data);
socket.onerror = () => socket.close(); socket.onerror = () => { socket.close(); };
socket.onclose = () => { socket.onclose = () => {
if (this.socket === socket) this.socket = undefined; if (this.socket === socket) this.socket = undefined;
this.scheduleReconnect(); this.scheduleReconnect();
@@ -112,7 +112,7 @@ export class GlobalSessionSocket {
window.clearTimeout(this.reconnectTimer); window.clearTimeout(this.reconnectTimer);
const delay = this.reconnectDelay; const delay = this.reconnectDelay;
this.reconnectDelay = Math.min(this.reconnectDelay * 1.6, 5000); this.reconnectDelay = Math.min(this.reconnectDelay * 1.6, 5000);
this.reconnectTimer = window.setTimeout(() => this.open(), delay); this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay);
} }
private async handleMessage(data: MessageEvent["data"]): Promise<void> { private async handleMessage(data: MessageEvent["data"]): Promise<void> {
@@ -121,12 +121,20 @@ export class GlobalSessionSocket {
} }
} }
function isSessionUiEvent(event: any): event is SessionUiEvent { function isSessionUiEvent(event: unknown): event is SessionUiEvent {
return ["assistant.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "status.update", "activity.update", "command.output", "session.error"].includes(event?.type); const type = eventType(event);
return ["assistant.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "status.update", "activity.update", "command.output", "session.error"].includes(type);
} }
function isGlobalSessionEvent(event: unknown): event is Extract<SessionUiEvent, { type: "status.update" | "activity.update" }> { function isGlobalSessionEvent(event: unknown): event is Extract<SessionUiEvent, { type: "status.update" | "activity.update" }> {
return typeof event === "object" && event !== null && ("type" in event) && ((event as any).type === "status.update" || (event as any).type === "activity.update"); const type = eventType(event);
return type === "status.update" || type === "activity.update";
}
function eventType(event: unknown): string {
if (typeof event !== "object" || event === null || !("type" in event)) return "";
const type = event.type;
return typeof type === "string" ? type : "";
} }
async function parseSocketEvent(data: MessageEvent["data"]): Promise<unknown> { async function parseSocketEvent(data: MessageEvent["data"]): Promise<unknown> {
@@ -141,12 +149,12 @@ async function parseSocketEvent(data: MessageEvent["data"]): Promise<unknown> {
} }
function closeSocketQuietly(socket: WebSocket | undefined): void { function closeSocketQuietly(socket: WebSocket | undefined): void {
if (!socket) return; if (socket === undefined) return;
socket.onmessage = null; socket.onmessage = null;
socket.onerror = null; socket.onerror = null;
socket.onclose = null; socket.onclose = null;
if (socket.readyState === WebSocket.CONNECTING) { if (socket.readyState === WebSocket.CONNECTING) {
socket.onopen = () => socket.close(); socket.onopen = () => { socket.close(); };
return; return;
} }
socket.close(); socket.close();
+8 -8
View File
@@ -3,7 +3,7 @@ import type { ChatLine } from "./components/shared";
import type { SessionUiEvent } from "./sessionSocket"; import type { SessionUiEvent } from "./sessionSocket";
export function shellStartMessage(command: string, excludeFromContext?: boolean): ChatLine { export function shellStartMessage(command: string, excludeFromContext?: boolean): ChatLine {
return textMessage("bash", `${excludeFromContext ? "excluded from context\n\n" : ""}$ ${command}`); return textMessage("bash", `${excludeFromContext === true ? "excluded from context\n\n" : ""}$ ${command}`);
} }
export function appendShellChunk(messages: ChatLine[], chunk: string): ChatLine[] { export function appendShellChunk(messages: ChatLine[], chunk: string): ChatLine[] {
@@ -19,13 +19,13 @@ export function finalizeShellMessage(messages: ChatLine[], event: Extract<Sessio
const lastPart = last?.parts.at(-1); const lastPart = last?.parts.at(-1);
if (last?.role !== "bash" || lastPart?.type !== "text") return messages; if (last?.role !== "bash" || lastPart?.type !== "text") return messages;
const notes: string[] = []; const notes: string[] = [];
if (!lastPart.text.includes("\n\n") && !event.output) notes.push("(no output)"); if (!lastPart.text.includes("\n\n") && (event.output === undefined || event.output === "")) notes.push("(no output)");
if (event.isError) notes.push(event.output ?? "Bash command failed"); if (event.isError === true) notes.push(event.output ?? "Bash command failed");
if (event.exitCode != null) notes.push(`exit ${event.exitCode}`); if (event.exitCode != null) notes.push(`exit ${String(event.exitCode)}`);
if (event.cancelled) notes.push("cancelled"); if (event.cancelled === true) notes.push("cancelled");
if (event.truncated) notes.push("output truncated"); if (event.truncated === true) notes.push("output truncated");
if (event.fullOutputPath) notes.push(`full output: ${event.fullOutputPath}`); if (event.fullOutputPath !== undefined && event.fullOutputPath !== "") notes.push(`full output: ${event.fullOutputPath}`);
if (!notes.length) return messages; if (notes.length === 0) return messages;
return [...messages.slice(0, -1), { ...last, parts: [...last.parts.slice(0, -1), { ...lastPart, text: `${lastPart.text}\n\n${notes.join("\n")}` }] }]; return [...messages.slice(0, -1), { ...last, parts: [...last.parts.slice(0, -1), { ...lastPart, text: `${lastPart.text}\n\n${notes.join("\n")}` }] }];
} }
+2 -2
View File
@@ -2,9 +2,9 @@ export function formatTokenCount(count: number): string {
if (!Number.isFinite(count)) return "0"; if (!Number.isFinite(count)) return "0";
if (count < 1000) return Math.round(count).toString(); if (count < 1000) return Math.round(count).toString();
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`; if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1_000_000) return `${Math.round(count / 1000)}k`; if (count < 1_000_000) return `${String(Math.round(count / 1000))}k`;
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`; if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
return `${Math.round(count / 1_000_000)}M`; return `${String(Math.round(count / 1_000_000))}M`;
} }
export function formatCost(cost: number): string { export function formatCost(cost: number): string {
+4 -4
View File
@@ -34,10 +34,10 @@ app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces"
} }
}); });
await registerSessionProxyRoutes(app); registerSessionProxyRoutes(app);
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other" } }>("/api/files", async (request, reply) => { app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other" } }>("/api/files", async (request, reply) => {
if (!request.query.cwd) return reply.code(400).send({ error: "cwd query parameter is required" }); if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try { try {
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind); return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
} catch (error) { } catch (error) {
@@ -51,6 +51,6 @@ if (existsSync(clientDist)) {
app.setNotFoundHandler((_request, reply) => reply.sendFile("index.html")); app.setNotFoundHandler((_request, reply) => reply.sendFile("index.html"));
} }
const port = Number(process.env.PI_WEB_PORT ?? process.env.PORT ?? 3000); const port = Number(process.env["PI_WEB_PORT"] ?? process.env["PORT"] ?? 3000);
const host = process.env.PI_WEB_HOST ?? "127.0.0.1"; const host = process.env["PI_WEB_HOST"] ?? "127.0.0.1";
await app.listen({ port, host }); await app.listen({ port, host });
+1 -1
View File
@@ -13,7 +13,7 @@ export class ProjectService {
const resolved = await realpath(input.path); const resolved = await realpath(input.path);
const s = await stat(resolved); const s = await stat(resolved);
if (!s.isDirectory()) throw new Error("Project path must be a directory"); if (!s.isDirectory()) throw new Error("Project path must be a directory");
return this.store.add({ name: input.name, path: resolved }); return this.store.add(input.name === undefined ? { path: resolved } : { name: input.name, path: resolved });
} }
async requireProject(id: string): Promise<Project> { async requireProject(id: string): Promise<Project> {
+3 -1
View File
@@ -11,7 +11,9 @@ export class SessionEventHub {
this.socketsBySession.set(sessionId, sockets); this.socketsBySession.set(sessionId, sockets);
} }
sockets.add(socket); sockets.add(socket);
socket.on("close", () => sockets?.delete(socket)); socket.on("close", () => {
sockets.delete(socket);
});
} }
addGlobal(socket: WebSocket): void { addGlobal(socket: WebSocket): void {
+5 -4
View File
@@ -12,12 +12,13 @@ await app.register(fastifyWebsocket);
const eventHub = new SessionEventHub(); const eventHub = new SessionEventHub();
const sessions = new PiSessionService(eventHub); const sessions = new PiSessionService(eventHub);
await registerSessionRoutes(app, sessions, eventHub); registerSessionRoutes(app, sessions, eventHub);
const port = process.env.PI_WEB_SESSIOND_PORT ? Number(process.env.PI_WEB_SESSIOND_PORT) : undefined; const portValue = process.env["PI_WEB_SESSIOND_PORT"];
const host = process.env.PI_WEB_SESSIOND_HOST ?? "127.0.0.1"; const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined;
const host = process.env["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";
if (port) { if (port !== undefined) {
await app.listen({ port, host }); await app.listen({ port, host });
} else { } else {
const path = sessiondSocketPath(); const path = sessiondSocketPath();
+2 -2
View File
@@ -2,9 +2,9 @@ import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
export function sessiondSocketPath(): string { export function sessiondSocketPath(): string {
return process.env.PI_WEB_SESSIOND_SOCKET ?? join(homedir(), ".pi-web", "sessiond.sock"); return process.env["PI_WEB_SESSIOND_SOCKET"] ?? join(homedir(), ".pi-web", "sessiond.sock");
} }
export function sessiondHttpUrl(): string | undefined { export function sessiondHttpUrl(): string | undefined {
return process.env.PI_WEB_SESSIOND_URL; return process.env["PI_WEB_SESSIOND_URL"];
} }
+14 -11
View File
@@ -8,12 +8,12 @@ export class SessionDaemonClient {
async request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> { async request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
const payload = body === undefined ? undefined : JSON.stringify(body); const payload = body === undefined ? undefined : JSON.stringify(body);
if (this.baseUrl) return this.requestUrl(method, path, payload); if (this.baseUrl !== undefined && this.baseUrl !== "") return this.requestUrl(method, path, payload);
return this.requestSocket(method, path, payload); return this.requestSocket(method, path, payload);
} }
connectWebSocket(path: string): WebSocket { connectWebSocket(path: string): WebSocket {
if (this.baseUrl) { if (this.baseUrl !== undefined && this.baseUrl !== "") {
const url = new URL(path, this.baseUrl); const url = new URL(path, this.baseUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return new WebSocket(url); return new WebSocket(url);
@@ -22,11 +22,12 @@ export class SessionDaemonClient {
} }
private async requestUrl(method: string, path: string, payload?: string) { private async requestUrl(method: string, path: string, payload?: string) {
const response = await fetch(new URL(path, this.baseUrl), { const init: RequestInit = { method };
method, if (payload !== undefined && payload !== "") {
headers: payload ? { "content-type": "application/json" } : undefined, init.headers = { "content-type": "application/json" };
body: payload, init.body = payload;
}); }
const response = await fetch(new URL(path, this.baseUrl), init);
return { return {
statusCode: response.status, statusCode: response.status,
headers: Object.fromEntries(response.headers.entries()), headers: Object.fromEntries(response.headers.entries()),
@@ -41,13 +42,15 @@ export class SessionDaemonClient {
socketPath: this.socketPath, socketPath: this.socketPath,
path, path,
method, method,
headers: payload headers: payload !== undefined && payload !== ""
? { "content-type": "application/json", "content-length": Buffer.byteLength(payload) } ? { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }
: undefined, : undefined,
}, },
(response) => { (response) => {
const chunks: Buffer[] = []; const chunks: Uint8Array[] = [];
response.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); response.on("data", (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on("end", () => { response.on("end", () => {
resolve({ resolve({
statusCode: response.statusCode ?? 500, statusCode: response.statusCode ?? 500,
@@ -58,7 +61,7 @@ export class SessionDaemonClient {
}, },
); );
request.on("error", reject); request.on("error", reject);
if (payload) request.write(payload); if (payload !== undefined && payload !== "") request.write(payload);
request.end(); request.end();
}); });
} }
+22 -12
View File
@@ -2,15 +2,17 @@ import type { FastifyInstance, FastifyReply } from "fastify";
import { WebSocket, type RawData } from "ws"; import { WebSocket, type RawData } from "ws";
import { SessionDaemonClient } from "./sessionDaemonClient.js"; import { SessionDaemonClient } from "./sessionDaemonClient.js";
export async function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): Promise<void> { export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): void {
const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => { const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => {
try { try {
const upstream = await daemon.request(request.method, stripApiPrefix(request.url), request.body); const upstream = await daemon.request(request.method, stripApiPrefix(request.url), request.body);
reply.code(upstream.statusCode); reply.code(upstream.statusCode);
if (upstream.headers["content-type"]) reply.header("content-type", upstream.headers["content-type"]); const contentType = upstream.headers["content-type"];
return upstream.body ? JSON.parse(upstream.body) : undefined; if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType);
return upstream.body !== "" ? parseJson(upstream.body) : undefined;
} catch (error) { } catch (error) {
requestFailed(reply, error); requestFailed(reply, error);
return undefined;
} }
}; };
@@ -36,22 +38,30 @@ export async function registerSessionProxyRoutes(app: FastifyInstance, daemon =
} }
function stripApiPrefix(url: string): string { function stripApiPrefix(url: string): string {
return url.startsWith("/api") ? url.slice(4) || "/" : url; const stripped = url.startsWith("/api") ? url.slice(4) : url;
return stripped === "" ? "/" : stripped;
} }
function requestFailed(reply: FastifyReply, error: unknown) { function parseJson(text: string): unknown {
const value: unknown = JSON.parse(text);
return value;
}
function requestFailed(reply: FastifyReply, error: unknown): void {
reply.code(502).send({ error: `Session daemon unavailable: ${error instanceof Error ? error.message : String(error)}` }); reply.code(502).send({ error: `Session daemon unavailable: ${error instanceof Error ? error.message : String(error)}` });
} }
function bridgeSockets(client: WebSocket, upstream: WebSocket): void { function bridgeSockets(client: WebSocket, upstream: WebSocket): void {
client.on("message", (data) => sendIfOpen(upstream, data)); client.on("message", (data) => { sendIfOpen(upstream, data); });
upstream.on("message", (data) => sendIfOpen(client, data)); upstream.on("message", (data) => { sendIfOpen(client, data); });
client.on("close", () => upstream.close()); client.on("close", () => { upstream.close(); });
upstream.on("close", () => client.close()); upstream.on("close", () => { client.close(); });
upstream.on("error", () => client.close()); upstream.on("error", () => { client.close(); });
client.on("error", () => upstream.close()); client.on("error", () => { upstream.close(); });
} }
function sendIfOpen(socket: WebSocket, data: RawData): void { function sendIfOpen(socket: WebSocket, data: RawData): void {
if (socket.readyState === WebSocket.OPEN) socket.send(data); if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
}
} }
+135 -61
View File
@@ -7,15 +7,18 @@ import {
ModelRegistry, ModelRegistry,
SessionManager, SessionManager,
type AgentSession, type AgentSession,
type AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory, type CreateAgentSessionRuntimeFactory,
} from "@mariozechner/pi-coding-agent"; } from "@mariozechner/pi-coding-agent";
import type { ClientCommand, ClientCommandResult, ClientSession, ClientSessionStatus } from "../types.js"; import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionStatus } from "../types.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import { BUILTIN_COMMANDS } from "./builtinCommands.js"; import { BUILTIN_COMMANDS } from "./builtinCommands.js";
import { SessionCommandService } from "./sessionCommandService.js"; import { SessionCommandService } from "./sessionCommandService.js";
import type { ActiveSession } from "./sessionRuntimeStore.js"; import type { ActiveSession } from "./sessionRuntimeStore.js";
function noop(): void {
// Intentionally empty default unsubscribe callback.
}
export class PiSessionService { export class PiSessionService {
private readonly active = new Map<string, ActiveSession>(); private readonly active = new Map<string, ActiveSession>();
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>(); private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
@@ -26,12 +29,15 @@ export class PiSessionService {
private readonly modelRegistry = ModelRegistry.create(this.authStorage); private readonly modelRegistry = ModelRegistry.create(this.authStorage);
private readonly createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => { private readonly createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd, agentDir, authStorage: this.authStorage, modelRegistry: this.modelRegistry }); const services = await createAgentSessionServices({ cwd, agentDir, authStorage: this.authStorage, modelRegistry: this.modelRegistry });
const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent }); const options = sessionStartEvent === undefined
? { services, sessionManager }
: { services, sessionManager, sessionStartEvent };
const result = await createAgentSessionFromServices(options);
return { ...result, services, diagnostics: services.diagnostics }; return { ...result, services, diagnostics: services.diagnostics };
}; };
constructor(private readonly events: SessionEventHub) { constructor(private readonly events: SessionEventHub) {
this.heartbeat = setInterval(() => this.publishHeartbeats(), 2000); this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, 2000);
this.commandService = new SessionCommandService( this.commandService = new SessionCommandService(
(sessionId) => this.getActive(sessionId), (sessionId) => this.getActive(sessionId),
(sessionId, text) => this.prompt(sessionId, text), (sessionId, text) => this.prompt(sessionId, text),
@@ -45,7 +51,7 @@ export class PiSessionService {
id: s.id, id: s.id,
path: s.path, path: s.path,
cwd: s.cwd, cwd: s.cwd,
name: s.name, ...(s.name === undefined ? {} : { name: s.name }),
created: s.created.toISOString(), created: s.created.toISOString(),
modified: s.modified.toISOString(), modified: s.modified.toISOString(),
messageCount: s.messageCount, messageCount: s.messageCount,
@@ -67,9 +73,15 @@ export class PiSessionService {
}; };
} }
async messages(sessionId: string): Promise<unknown[]> { async messages(sessionId: string, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
const session = await this.getOrOpen(sessionId); const session = await this.getOrOpen(sessionId);
return session.messages; const messages = historyMessages(session);
if (page?.before === undefined && page?.limit === undefined) return messages;
const total = messages.length;
const before = clampInteger(page.before ?? total, 0, total);
const limit = clampInteger(page.limit ?? 100, 1, 500);
const start = Math.max(0, before - limit);
return { messages: messages.slice(start, before), start, total };
} }
async status(sessionId: string): Promise<ClientSessionStatus> { async status(sessionId: string): Promise<ClientSessionStatus> {
@@ -80,7 +92,7 @@ export class PiSessionService {
const session = await this.getOrOpen(sessionId); const session = await this.getOrOpen(sessionId);
const commands: ClientCommand[] = [...BUILTIN_COMMANDS]; const commands: ClientCommand[] = [...BUILTIN_COMMANDS];
for (const command of session.extensionRunner.getRegisteredCommands()) { for (const command of session.extensionRunner.getRegisteredCommands()) {
commands.push({ name: command.invocationName, description: command.description, source: "extension" }); commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
} }
for (const template of session.promptTemplates) { for (const template of session.promptTemplates) {
commands.push({ name: template.name, description: template.description, source: "prompt" }); commands.push({ name: template.name, description: template.description, source: "prompt" });
@@ -94,7 +106,7 @@ export class PiSessionService {
async prompt(sessionId: string, text: string): Promise<void> { async prompt(sessionId: string, text: string): Promise<void> {
const session = await this.getOrOpen(sessionId); const session = await this.getOrOpen(sessionId);
this.publishActivity(session, "prompt accepted", "active"); this.publishActivity(session, "prompt accepted", "active");
void session.prompt(text).catch((error) => { void session.prompt(text).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "error", "error", message); this.publishActivity(session, "error", "error", message);
this.events.publish(sessionId, { type: "session.error", message }); this.events.publish(sessionId, { type: "session.error", message });
@@ -126,7 +138,7 @@ export class PiSessionService {
}); });
this.publishActivity(session, "bash complete", result.exitCode === 0 ? "idle" : "error", command); this.publishActivity(session, "bash complete", result.exitCode === 0 ? "idle" : "error", command);
this.publishStatus(session); this.publishStatus(session);
}).catch((error) => { }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.events.publish(session.sessionId, { type: "shell.end", output: message, isError: true }); this.events.publish(session.sessionId, { type: "shell.end", output: message, isError: true });
this.events.publish(session.sessionId, { type: "session.error", message }); this.events.publish(session.sessionId, { type: "session.error", message });
@@ -172,9 +184,12 @@ export class PiSessionService {
private async create(sessionManager: SessionManager, cwd: string): Promise<ActiveSession> { private async create(sessionManager: SessionManager, cwd: string): Promise<ActiveSession> {
const runtime = await createAgentSessionRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager }); const runtime = await createAgentSessionRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
const active: ActiveSession = { runtime, unsubscribe: () => {} }; const active: ActiveSession = { runtime, unsubscribe: noop };
this.bindRuntime(active); this.bindRuntime(active);
runtime.setRebindSession(async () => this.bindRuntime(active)); runtime.setRebindSession(() => {
this.bindRuntime(active);
return Promise.resolve();
});
this.active.set(runtime.session.sessionId, active); this.active.set(runtime.session.sessionId, active);
this.publishStatus(runtime.session); this.publishStatus(runtime.session);
return active; return active;
@@ -214,9 +229,11 @@ export class PiSessionService {
return "active"; return "active";
} }
private publishActivityForEvent(session: AgentSession, event: any): void { private publishActivityForEvent(session: AgentSession, event: unknown): void {
if (event.type === "agent_start") return this.publishActivity(session, "agent running", "active"); const eventType = getString(event, "type");
if (event.type === "agent_end") { if (eventType === undefined) return;
if (eventType === "agent_start") { this.publishActivity(session, "agent running", "active"); return; }
if (eventType === "agent_end") {
this.publishActivity(session, "idle", "idle"); this.publishActivity(session, "idle", "idle");
setTimeout(() => { setTimeout(() => {
this.publishActivity(session, "idle", "idle"); this.publishActivity(session, "idle", "idle");
@@ -224,21 +241,26 @@ export class PiSessionService {
}, 250); }, 250);
return; return;
} }
if (event.type === "turn_end") return this.publishActivity(session, "turn complete", "active"); if (eventType === "turn_end") { this.publishActivity(session, "turn complete", "active"); return; }
if (event.type === "message_start") return this.publishActivity(session, "message started", "active"); if (eventType === "message_start") { this.publishActivity(session, "message started", "active"); return; }
if (event.type === "message_end") return this.publishActivity(session, "message complete", "idle"); if (eventType === "message_end") { this.publishActivity(session, "message complete", "idle"); return; }
if (event.type === "message_update") return this.publishActivity(session, "receiving response", "active"); if (eventType === "message_update") { this.publishActivity(session, "receiving response", "active"); return; }
if (event.type === "tool_execution_start") return this.publishActivity(session, "running tool", "active", event.toolName); if (eventType === "tool_execution_start") { this.publishActivity(session, "running tool", "active", getString(event, "toolName")); return; }
if (event.type === "tool_execution_end") return this.publishActivity(session, event.isError ? "tool failed" : "tool complete", event.isError ? "error" : "active", event.toolName); if (eventType === "tool_execution_end") {
if (event.type === "bash_execution_start") return this.publishActivity(session, "running bash", "active"); const isError = getBoolean(event, "isError") === true;
if (event.type === "bash_execution_end") return this.publishActivity(session, "bash complete", "active"); this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "active", getString(event, "toolName"));
this.publishActivity(session, event.type.replaceAll("_", " "), "active"); return;
}
if (eventType === "bash_execution_start") { this.publishActivity(session, "running bash", "active"); return; }
if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "active"); return; }
this.publishActivity(session, eventType.replaceAll("_", " "), "active");
} }
private publishActivity(session: AgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void { private publishActivity(session: AgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
const at = new Date().toISOString(); const at = new Date().toISOString();
this.activities.set(session.sessionId, { phase, label, detail, at }); const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
const activity = { sessionId: session.sessionId, phase, label, detail, at }; this.activities.set(session.sessionId, stored);
const activity = detail === undefined ? { sessionId: session.sessionId, phase, label, at } : { sessionId: session.sessionId, phase, label, detail, at };
this.events.publish(session.sessionId, { type: "activity.update", activity }); this.events.publish(session.sessionId, { type: "activity.update", activity });
this.events.publishGlobal({ type: "activity.update", activity }); this.events.publishGlobal({ type: "activity.update", activity });
} }
@@ -251,17 +273,23 @@ export class PiSessionService {
private statusFromSession(session: AgentSession): ClientSessionStatus { private statusFromSession(session: AgentSession): ClientSessionStatus {
const stats = session.getSessionStats(); const stats = session.getSessionStats();
return { const model = session.model === undefined
sessionId: session.sessionId, ? undefined
model: session.model : (() => {
? { const name = getString(session.model, "name");
const reasoning = getProperty(session.model, "reasoning");
return {
provider: session.model.provider, provider: session.model.provider,
id: session.model.id, id: session.model.id,
name: (session.model as any).name, ...(name === undefined ? {} : { name }),
contextWindow: session.model.contextWindow, contextWindow: session.model.contextWindow,
reasoning: (session.model as any).reasoning, ...(reasoning === undefined ? {} : { reasoning }),
} };
: undefined, })();
const contextUsage = session.getContextUsage();
return {
sessionId: session.sessionId,
...(model === undefined ? {} : { model }),
thinkingLevel: session.thinkingLevel, thinkingLevel: session.thinkingLevel,
isStreaming: session.isStreaming, isStreaming: session.isStreaming,
isCompacting: session.isCompacting, isCompacting: session.isCompacting,
@@ -269,33 +297,54 @@ export class PiSessionService {
pendingMessageCount: session.pendingMessageCount, pendingMessageCount: session.pendingMessageCount,
tokens: stats.tokens, tokens: stats.tokens,
cost: stats.cost, cost: stats.cost,
contextUsage: session.getContextUsage(), ...(contextUsage === undefined ? {} : { contextUsage }),
}; };
} }
} }
function toClientEvent(event: any): unknown { function historyMessages(session: AgentSession): unknown[] {
if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") { const messages: unknown[] = [];
return { type: "assistant.delta", text: event.assistantMessageEvent.delta }; for (const entry of session.sessionManager.getBranch()) {
if (entry.type === "message") messages.push(entry.message);
else if (entry.type === "custom_message" && entry.display) messages.push({ role: "custom", content: entry.content, customType: entry.customType, details: entry.details });
else if (entry.type === "compaction") messages.push({ role: "system", content: `Compacted history:\n\n${entry.summary}` });
else if (entry.type === "branch_summary") messages.push({ role: "system", content: `Branch summary:\n\n${entry.summary}` });
} }
if (event.type === "tool_execution_start") { return messages;
return { type: "tool.start", toolName: event.toolName, toolCallId: event.toolCallId, summary: summarizeToolArgs(event.args) };
}
if (event.type === "tool_execution_end") {
return { type: "tool.end", toolName: event.toolName, toolCallId: event.toolCallId, text: stringifyToolResult(event.result), isError: event.isError };
}
if (event.type === "agent_start") return { type: "agent.start" };
if (event.type === "agent_end") return { type: "agent.end" };
if (event.type === "message_end") return { type: "message.end" };
return { type: "pi.event", eventType: event.type };
} }
function summarizeToolArgs(args: any): string { function clampInteger(value: number, min: number, max: number): number {
if (!args || typeof args !== "object") return args == null ? "" : String(args); if (!Number.isFinite(value)) return max;
if (typeof args.command === "string") return args.command; return Math.max(min, Math.min(max, Math.floor(value)));
if (typeof args.path === "string") return args.path; }
if (typeof args.oldText === "string" && typeof args.newText === "string") return "edit text replacement";
if (Array.isArray(args.edits)) return `${args.edits.length} edit${args.edits.length === 1 ? "" : "s"}`; function toClientEvent(event: unknown): unknown {
const eventType = getString(event, "type");
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
}
if (eventType === "tool_execution_start") {
return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(getProperty(event, "args")) };
}
if (eventType === "tool_execution_end") {
return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(getProperty(event, "result")), isError: getBoolean(event, "isError") === true };
}
if (eventType === "agent_start") return { type: "agent.start" };
if (eventType === "agent_end") return { type: "agent.end" };
if (eventType === "message_end") return { type: "message.end" };
return { type: "pi.event", eventType: eventType ?? "unknown" };
}
function summarizeToolArgs(args: unknown): string {
if (!isRecord(args)) return stringifyPrimitive(args);
const command = getString(args, "command");
if (command !== undefined) return command;
const path = getString(args, "path");
if (path !== undefined) return path;
if (typeof args["oldText"] === "string" && typeof args["newText"] === "string") return "edit text replacement";
const edits = args["edits"];
if (Array.isArray(edits)) return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3); const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
return entries.map(([key, value]) => `${key}: ${shortToolValue(value)}`).join(" · "); return entries.map(([key, value]) => `${key}: ${shortToolValue(value)}`).join(" · ");
} }
@@ -303,18 +352,43 @@ function summarizeToolArgs(args: any): string {
function shortToolValue(value: unknown): string { function shortToolValue(value: unknown): string {
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}` : value; if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}` : value;
if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`; if (Array.isArray(value)) return `${String(value.length)} item${value.length === 1 ? "" : "s"}`;
if (typeof value === "object" && value) return "object"; if (typeof value === "object" && value !== null) return "object";
return ""; return "";
} }
function stringifyToolResult(result: unknown): string { function stringifyToolResult(result: unknown): string {
if (typeof result === "string") return result; if (typeof result === "string") return result;
if (Array.isArray(result)) return result.map(stringifyToolResult).filter(Boolean).join("\n"); if (Array.isArray(result)) return result.map(stringifyToolResult).filter((text) => text !== "").join("\n");
if (result && typeof result === "object") { if (isRecord(result)) {
const text = (result as any).text ?? (result as any).content ?? (result as any).output; const text = getString(result, "text") ?? getString(result, "content") ?? getString(result, "output");
if (typeof text === "string") return text; if (text !== undefined) return text;
return JSON.stringify(result, null, 2); return JSON.stringify(result, null, 2);
} }
return result == null ? "" : String(result); return stringifyPrimitive(result);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getProperty(value: unknown, key: string): unknown {
return isRecord(value) ? value[key] : undefined;
}
function getString(value: unknown, key: string): string | undefined {
const property = getProperty(value, key);
return typeof property === "string" ? property : undefined;
}
function getBoolean(value: unknown, key: string): boolean | undefined {
const property = getProperty(value, key);
return typeof property === "boolean" ? property : undefined;
}
function stringifyPrimitive(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
return "";
} }
+13 -16
View File
@@ -44,26 +44,23 @@ export class SessionCommandService {
async respond(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> { async respond(sessionId: string, requestId: string, value: string): Promise<ClientCommandResult> {
const pending = this.pendingSelects.get(requestId); const pending = this.pendingSelects.get(requestId);
if (!pending || pending.sessionId !== sessionId) return { type: "unsupported", message: "Command request expired" }; if (pending?.sessionId !== sessionId) return { type: "unsupported", message: "Command request expired" };
this.pendingSelects.delete(requestId); this.pendingSelects.delete(requestId);
const active = await this.getActive(sessionId); const active = await this.getActive(sessionId);
if (pending.command === "fork") { const result = await active.runtime.fork(value);
const result = await active.runtime.fork(value); if (result.cancelled) return { type: "done", message: "Fork cancelled" };
if (result.cancelled) return { type: "done", message: "Fork cancelled" }; return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime) };
return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime) };
}
return { type: "unsupported", message: "Unsupported command response" };
} }
private nameSession(active: ActiveSession, name: string): ClientCommandResult { private nameSession(active: ActiveSession, name: string): ClientCommandResult {
if (!name) return { type: "unsupported", message: "Usage: /name <session name>" }; if (name === "") return { type: "unsupported", message: "Usage: /name <session name>" };
active.runtime.session.setSessionName(name); active.runtime.session.setSessionName(name);
return { type: "done", message: `Session named: ${name}`, session: clientSessionFromRuntime(active.runtime) }; return { type: "done", message: `Session named: ${name}`, session: clientSessionFromRuntime(active.runtime) };
} }
private compact(session: AgentSession, instructions: string): ClientCommandResult { private compact(session: AgentSession, instructions: string): ClientCommandResult {
void session.compact(instructions || undefined) void session.compact(instructions === "" ? undefined : instructions)
.then((result) => { .then((result) => {
this.events.publish(session.sessionId, { this.events.publish(session.sessionId, {
type: "command.output", type: "command.output",
@@ -71,7 +68,7 @@ export class SessionCommandService {
message: formatCompactionResult(result), message: formatCompactionResult(result),
}); });
}) })
.catch((error) => { .catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.events.publish(session.sessionId, { type: "command.output", level: "error", message: `Compaction failed: ${message}` }); this.events.publish(session.sessionId, { type: "command.output", level: "error", message: `Compaction failed: ${message}` });
this.events.publish(session.sessionId, { type: "session.error", message }); this.events.publish(session.sessionId, { type: "session.error", message });
@@ -81,7 +78,7 @@ export class SessionCommandService {
private async clone(active: ActiveSession): Promise<ClientCommandResult> { private async clone(active: ActiveSession): Promise<ClientCommandResult> {
const leafId = active.runtime.session.sessionManager.getLeafId(); const leafId = active.runtime.session.sessionManager.getLeafId();
if (!leafId) return { type: "unsupported", message: "Cannot clone: no current session entry" }; if (leafId === null || leafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
const result = await active.runtime.fork(leafId, { position: "at" }); const result = await active.runtime.fork(leafId, { position: "at" });
if (result.cancelled) return { type: "done", message: "Clone cancelled" }; if (result.cancelled) return { type: "done", message: "Clone cancelled" };
return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) }; return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) };
@@ -113,7 +110,7 @@ function clientSessionFromRuntime(runtime: AgentSessionRuntime): ClientSession {
id: session.sessionId, id: session.sessionId,
path: session.sessionFile ?? "", path: session.sessionFile ?? "",
cwd: runtime.cwd, cwd: runtime.cwd,
name: session.sessionName, ...(session.sessionName === undefined ? {} : { name: session.sessionName }),
created: new Date().toISOString(), created: new Date().toISOString(),
modified: new Date().toISOString(), modified: new Date().toISOString(),
messageCount: session.messages.length, messageCount: session.messages.length,
@@ -125,9 +122,9 @@ function formatSessionStats(session: AgentSession): string {
const stats = session.getSessionStats(); const stats = session.getSessionStats();
return [ return [
`Session: ${stats.sessionId}`, `Session: ${stats.sessionId}`,
`Messages: ${stats.totalMessages} (${stats.userMessages} user, ${stats.assistantMessages} assistant)`, `Messages: ${String(stats.totalMessages)} (${String(stats.userMessages)} user, ${String(stats.assistantMessages)} assistant)`,
`Tool calls: ${stats.toolCalls}`, `Tool calls: ${String(stats.toolCalls)}`,
`Tokens: ↑${stats.tokens.input}${stats.tokens.output} total ${stats.tokens.total}`, `Tokens: ↑${String(stats.tokens.input)}${String(stats.tokens.output)} total ${String(stats.tokens.total)}`,
`Cost: $${stats.cost.toFixed(4)}`, `Cost: $${stats.cost.toFixed(4)}`,
].join("\n"); ].join("\n");
} }
@@ -135,7 +132,7 @@ function formatSessionStats(session: AgentSession): string {
function formatCompactionResult(result: { summary: string; tokensBefore: number }): string { function formatCompactionResult(result: { summary: string; tokensBefore: number }): string {
return [ return [
"Compaction complete.", "Compaction complete.",
`Tokens before: ${result.tokensBefore}`, `Tokens before: ${String(result.tokensBefore)}`,
"", "",
result.summary, result.summary,
].join("\n"); ].join("\n");
+16 -5
View File
@@ -2,9 +2,9 @@ import type { FastifyInstance } from "fastify";
import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiSessionService } from "./piSessionService.js"; import type { PiSessionService } from "./piSessionService.js";
export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): Promise<void> { export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
app.get<{ Querystring: { cwd?: string } }>(`${prefix}/sessions`, async (request, reply) => { app.get<{ Querystring: { cwd?: string } }>(`${prefix}/sessions`, async (request, reply) => {
if (!request.query.cwd) return reply.code(400).send({ error: "cwd query parameter is required" }); if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
return sessions.list(request.query.cwd); return sessions.list(request.query.cwd);
}); });
@@ -16,9 +16,10 @@ export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSe
} }
}); });
app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => { app.get<{ Params: { sessionId: string }; Querystring: { before?: string; limit?: string } }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
try { try {
return await sessions.messages(request.params.sessionId); const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) };
return await sessions.messages(request.params.sessionId, page);
} catch (error) { } catch (error) {
return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) });
} }
@@ -79,7 +80,7 @@ export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSe
return { aborted: true }; return { aborted: true };
}); });
app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/stop`, async (request) => { app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/stop`, (request) => {
sessions.stop(request.params.sessionId); sessions.stop(request.params.sessionId);
return { stopped: true }; return { stopped: true };
}); });
@@ -92,3 +93,13 @@ export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSe
eventHub.addGlobal(socket); eventHub.addGlobal(socket);
}); });
} }
function optionalField<T>(key: string, value: T | undefined): Record<string, T> | object {
return value === undefined ? {} : { [key]: value };
}
function optionalNumber(value: string | undefined): number | undefined {
if (value === undefined || value === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
+30 -4
View File
@@ -8,6 +8,29 @@ interface ProjectFile {
projects: Project[]; projects: Project[];
} }
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
function parseProjectFile(value: unknown): ProjectFile {
if (!isRecord(value) || !Array.isArray(value["projects"])) throw new Error("Invalid project file");
return { projects: value["projects"].map(parseProject) };
}
function parseProject(value: unknown): Project {
if (!isRecord(value)) throw new Error("Invalid project");
const id = value["id"];
const name = value["name"];
const path = value["path"];
const createdAt = value["createdAt"];
if (typeof id !== "string" || typeof name !== "string" || typeof path !== "string" || typeof createdAt !== "string") throw new Error("Invalid project");
return { id, name, path, createdAt };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export class ProjectStore { export class ProjectStore {
constructor(private readonly filePath = join(homedir(), ".pi-web", "projects.json")) {} constructor(private readonly filePath = join(homedir(), ".pi-web", "projects.json")) {}
@@ -21,9 +44,11 @@ export class ProjectStore {
const existing = data.projects.find((p) => p.path === path); const existing = data.projects.find((p) => p.path === path);
if (existing) return existing; if (existing) return existing;
const trimmedName = input.name?.trim();
const leafName = path.split("/").filter((part) => part !== "").at(-1);
const project: Project = { const project: Project = {
id: randomUUID(), id: randomUUID(),
name: input.name?.trim() || path.split("/").filter(Boolean).at(-1) || path, name: trimmedName !== undefined && trimmedName !== "" ? trimmedName : leafName ?? path,
path, path,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
@@ -38,9 +63,10 @@ export class ProjectStore {
private async read(): Promise<ProjectFile> { private async read(): Promise<ProjectFile> {
try { try {
return JSON.parse(await readFile(this.filePath, "utf8")) as ProjectFile; const value: unknown = JSON.parse(await readFile(this.filePath, "utf8"));
} catch (error: any) { return parseProjectFile(value);
if (error?.code === "ENOENT") return { projects: [] }; } catch (error: unknown) {
if (isNodeErrorWithCode(error, "ENOENT")) return { projects: [] };
throw error; throw error;
} }
} }
+6
View File
@@ -26,6 +26,12 @@ export interface ClientSession {
firstMessage: string; firstMessage: string;
} }
export interface ClientMessagePage {
messages: unknown[];
start: number;
total: number;
}
export interface ClientSessionStatus { export interface ClientSessionStatus {
sessionId: string; sessionId: string;
model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown }; model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown };
+12 -9
View File
@@ -14,15 +14,18 @@ export class WorkspaceService {
const worktrees = await discoverGitWorktrees(project.path); const worktrees = await discoverGitWorktrees(project.path);
if (worktrees.length === 0) return [this.single(project)]; if (worktrees.length === 0) return [this.single(project)];
return worktrees.map((worktree) => ({ return worktrees.map((worktree) => {
id: idFor(`${project.id}:${worktree.path}`), const leafName = worktree.path.split("/").filter((part) => part !== "").at(-1);
projectId: project.id, return {
path: worktree.path, id: idFor(`${project.id}:${worktree.path}`),
label: worktree.branch || (worktree.detached ? "detached" : worktree.path.split("/").filter(Boolean).at(-1) || worktree.path), projectId: project.id,
branch: worktree.branch, path: worktree.path,
isMain: worktree.path === project.path, label: worktree.branch ?? (worktree.detached === true ? "detached" : leafName ?? worktree.path),
isGitWorktree: true, ...(worktree.branch === undefined ? {} : { branch: worktree.branch }),
})); isMain: worktree.path === project.path,
isGitWorktree: true,
};
});
} }
private single(project: Project): Workspace { private single(project: Project): Workspace {
+9
View File
@@ -4,6 +4,15 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"noEmit": true, "noEmit": true,
"skipLibCheck": true, "skipLibCheck": true,
"experimentalDecorators": true, "experimentalDecorators": true,