Archived
Add paged chat history loading
This commit is contained in:
+228
-22
@@ -64,38 +64,49 @@ export interface CommandOption {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MessagePage {
|
||||
messages: unknown[];
|
||||
start: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type CommandResult =
|
||||
| { type: "done"; message?: string; session?: SessionInfo }
|
||||
| { type: "select"; requestId: string; title: string; options: CommandOption[] }
|
||||
| { type: "unsupported"; message: string };
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init?.headers },
|
||||
});
|
||||
async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("content-type", "application/json");
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? response.statusText);
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
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 = {
|
||||
projects: () => request<Project[]>("/api/projects"),
|
||||
addProject: (path: string, name?: string) => request<Project>("/api/projects", { method: "POST", body: JSON.stringify({ path, name }) }),
|
||||
workspaces: (projectId: string) => request<Workspace[]>(`/api/projects/${projectId}/workspaces`),
|
||||
sessions: (cwd: string) => request<SessionInfo[]>(`/api/sessions?cwd=${encodeURIComponent(cwd)}`),
|
||||
startSession: (cwd: string) => request<SessionInfo>("/api/sessions", { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||
messages: (sessionId: string) => request<any[]>(`/api/sessions/${sessionId}/messages`),
|
||||
status: (sessionId: string) => request<SessionStatus>(`/api/sessions/${sessionId}/status`),
|
||||
commands: (sessionId: string) => request<SlashCommand[]>(`/api/sessions/${sessionId}/commands`),
|
||||
files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request<FileSuggestion[]>(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind ? `&kind=${encodeURIComponent(kind)}` : ""}`),
|
||||
prompt: (sessionId: string, text: string) => request<{ accepted: true }>(`/api/sessions/${sessionId}/prompt`, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
shell: (sessionId: string, text: string) => request<{ accepted: true }>(`/api/sessions/${sessionId}/shell`, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
runCommand: (sessionId: string, text: string) => request<CommandResult>(`/api/sessions/${sessionId}/commands/run`, { 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 }) }),
|
||||
stop: (sessionId: string) => request<{ stopped: true }>(`/api/sessions/${sessionId}/stop`, { method: "POST" }),
|
||||
projects: () => request("/api/projects", arrayOf(parseProject)),
|
||||
addProject: (path: string, name?: string) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name }) }),
|
||||
workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
||||
sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
||||
startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
|
||||
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
|
||||
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
||||
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(`/api/sessions/${sessionId}/prompt`, parseAccepted, { 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(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
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(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
||||
};
|
||||
|
||||
export function sessionEvents(sessionId: string): WebSocket {
|
||||
@@ -106,7 +117,202 @@ export function globalSessionEvents(): WebSocket {
|
||||
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 {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -6,14 +6,17 @@ export interface AppState {
|
||||
workspaces: Workspace[];
|
||||
sessions: SessionInfo[];
|
||||
messages: ChatLine[];
|
||||
selectedProject?: Project;
|
||||
selectedWorkspace?: Workspace;
|
||||
selectedSession?: SessionInfo;
|
||||
status?: SessionStatus;
|
||||
activity?: SessionActivity;
|
||||
messagePageStart: number;
|
||||
messagePageTotal: number;
|
||||
isLoadingEarlierMessages: boolean;
|
||||
selectedProject: Project | undefined;
|
||||
selectedWorkspace: Workspace | undefined;
|
||||
selectedSession: SessionInfo | undefined;
|
||||
status: SessionStatus | undefined;
|
||||
activity: SessionActivity | undefined;
|
||||
sessionStatuses: Record<string, SessionStatus>;
|
||||
sessionActivities: Record<string, SessionActivity>;
|
||||
commandDialog?: Extract<CommandResult, { type: "select" }>;
|
||||
commandDialog: Extract<CommandResult, { type: "select" }> | undefined;
|
||||
error: string;
|
||||
}
|
||||
|
||||
@@ -23,8 +26,17 @@ export function initialAppState(): AppState {
|
||||
workspaces: [],
|
||||
sessions: [],
|
||||
messages: [],
|
||||
messagePageStart: 0,
|
||||
messagePageTotal: 0,
|
||||
isLoadingEarlierMessages: false,
|
||||
selectedProject: undefined,
|
||||
selectedWorkspace: undefined,
|
||||
selectedSession: undefined,
|
||||
status: undefined,
|
||||
activity: undefined,
|
||||
sessionStatuses: {},
|
||||
sessionActivities: {},
|
||||
commandDialog: undefined,
|
||||
error: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export type ChatGroup =
|
||||
| { kind: "message"; message: ChatLine; index: number }
|
||||
| { kind: "group"; messages: ChatLine[]; startIndex: number };
|
||||
|
||||
export function groupChatMessages(messages: ChatLine[]): ChatGroup[] {
|
||||
export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGroup[] {
|
||||
const groups: ChatGroup[] = [];
|
||||
let eventMessages: ChatLine[] = [];
|
||||
let eventStartIndex = 0;
|
||||
@@ -23,10 +23,11 @@ export function groupChatMessages(messages: ChatLine[]): ChatGroup[] {
|
||||
const readableParts = 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) {
|
||||
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();
|
||||
@@ -38,8 +39,8 @@ export function summarizeChatGroup(messages: ChatLine[]): string {
|
||||
acc[message.role] = (acc[message.role] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const details = Object.entries(counts).map(([role, count]) => `${count} ${role}`).join(" · ");
|
||||
return `${messages.length} ${messages.length === 1 ? "event" : "events"}${details ? ` · ${details}` : ""}`;
|
||||
const details = Object.entries(counts).map(([role, count]) => `${String(count)} ${role}`).join(" · ");
|
||||
return `${String(messages.length)} ${messages.length === 1 ? "event" : "events"}${details !== "" ? ` · ${details}` : ""}`;
|
||||
}
|
||||
|
||||
function isReadablePart(message: ChatLine, part: ChatPart): boolean {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -20,23 +20,27 @@ export function appendText(messages: ChatLine[], role: ChatLine["role"], text: s
|
||||
return [...messages, textMessage(role, text)];
|
||||
}
|
||||
|
||||
function normalizeMessage(message: any): ChatLine[] {
|
||||
if (message?.role === "bashExecution") return [normalizeBashExecution(message)];
|
||||
const role = normalizeRole(message?.role);
|
||||
const parts = normalizeContent(message?.content, message);
|
||||
function normalizeMessage(message: unknown): ChatLine[] {
|
||||
if (getString(message, "role") === "bashExecution") return [normalizeBashExecution(message)];
|
||||
const role = normalizeRole(getString(message, "role"));
|
||||
const parts = normalizeContent(getProperty(message, "content"), message);
|
||||
if (role === "tool") return [{ role, parts }];
|
||||
|
||||
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 {
|
||||
const lines = message.excludeFromContext ? ["excluded from context", "", `$ ${message.command ?? ""}`] : [`$ ${message.command ?? ""}`];
|
||||
if (message.output) lines.push("", String(message.output));
|
||||
if (message.exitCode != null) lines.push("", `exit ${message.exitCode}`);
|
||||
if (message.cancelled) lines.push("", "cancelled");
|
||||
if (message.truncated) lines.push("", "output truncated");
|
||||
if (message.fullOutputPath) lines.push("", `full output: ${message.fullOutputPath}`);
|
||||
function normalizeBashExecution(message: unknown): ChatLine {
|
||||
const command = getString(message, "command") ?? "";
|
||||
const lines = getBoolean(message, "excludeFromContext") === true ? ["excluded from context", "", `$ ${command}`] : [`$ ${command}`];
|
||||
const output = getProperty(message, "output");
|
||||
if (output != null) lines.push("", stringifyPrimitive(output));
|
||||
const exitCode = getProperty(message, "exitCode");
|
||||
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") }] };
|
||||
}
|
||||
|
||||
@@ -47,33 +51,41 @@ function normalizeRole(role: unknown): ChatLine["role"] {
|
||||
return "system";
|
||||
}
|
||||
|
||||
function normalizeContent(content: unknown, message: any): ChatPart[] {
|
||||
if (typeof content === "string") return content ? [{ type: "text", text: content }] : [];
|
||||
function normalizeContent(content: unknown, message: unknown): ChatPart[] {
|
||||
if (typeof content === "string") return content !== "" ? [{ type: "text", text: content }] : [];
|
||||
if (!Array.isArray(content)) return objectFallback(content);
|
||||
|
||||
return content.flatMap((part: any): ChatPart[] => {
|
||||
if (part?.type === "text") return part.text ? [{ type: "text", text: part.text }] : [];
|
||||
if (part?.type === "thinking") return part.thinking || part.text ? [{ type: "thinking", text: part.thinking ?? part.text }] : [];
|
||||
if (part?.type === "toolCall") return [{ type: "toolCall", toolName: part.name ?? "tool", summary: summarizeArgs(part.arguments) }];
|
||||
if (part?.type === "image") return [{ type: "text", text: "[image]" }];
|
||||
return content.flatMap((part): ChatPart[] => {
|
||||
const type = getString(part, "type");
|
||||
const text = getString(part, "text");
|
||||
if (type === "text") return text !== undefined && text !== "" ? [{ type: "text", text }] : [];
|
||||
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);
|
||||
}).map((part) => part.type === "text" && message?.role === "toolResult"
|
||||
? { type: "toolResult", toolName: message.toolName ?? "tool", text: part.text, isError: !!message.isError }
|
||||
}).map((part) => part.type === "text" && getString(message, "role") === "toolResult"
|
||||
? { type: "toolResult", toolName: getString(message, "toolName") ?? "tool", text: part.text, isError: getBoolean(message, "isError") === true }
|
||||
: part);
|
||||
}
|
||||
|
||||
function objectFallback(value: unknown): ChatPart[] {
|
||||
if (value == null) return [];
|
||||
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 {
|
||||
if (!args || typeof args !== "object") return args == null ? "" : String(args);
|
||||
if (typeof args.command === "string") return args.command;
|
||||
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 summarizeArgs(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);
|
||||
return entries.map(([key, value]) => `${key}: ${shortValue(value)}`).join(" · ");
|
||||
}
|
||||
@@ -81,7 +93,32 @@ function summarizeArgs(args: any): string {
|
||||
function shortValue(value: unknown): string {
|
||||
if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}…` : value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
||||
if (typeof value === "object" && value) return "object";
|
||||
if (Array.isArray(value)) return `${String(value.length)} item${value.length === 1 ? "" : "s"}`;
|
||||
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 "";
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export class AutocompleteMenu extends LitElement {
|
||||
@property({ type: Number }) selectedIndex = 0;
|
||||
@property({ attribute: false }) onPick?: (item: CompletionItem) => void;
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
if (!this.items.length) return null;
|
||||
return html`
|
||||
<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); }}>
|
||||
<strong>${item.insertText}</strong>
|
||||
<span>${item.detail}</span>
|
||||
${item.description ? html`<small>${item.description}</small>` : null}
|
||||
${item.description !== undefined && item.description !== "" ? html`<small>${item.description}</small>` : null}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = autocompleteStyles;
|
||||
static override styles = autocompleteStyles;
|
||||
}
|
||||
|
||||
@@ -5,41 +5,92 @@ import type { ChatLine, ChatPart } from "./shared";
|
||||
import { chatStyles } from "./shared";
|
||||
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")
|
||||
export class ChatView extends LitElement {
|
||||
@property({ attribute: false }) messages: ChatLine[] = [];
|
||||
@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;
|
||||
@state() private pinnedToBottom = true;
|
||||
@state() private openGroupKeys = new Set<string>();
|
||||
@state() private loadedScrollPercent = 100;
|
||||
private suppressScrollSave = false;
|
||||
private saveScrollTimer?: number;
|
||||
|
||||
disconnectedCallback(): void {
|
||||
override disconnectedCallback(): void {
|
||||
window.clearTimeout(this.saveScrollTimer);
|
||||
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();
|
||||
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("messages") && this.pinnedToBottom) this.scrollToBottom();
|
||||
this.updateLoadedScrollPercent();
|
||||
}
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return html`
|
||||
<div class="chat" @scroll=${this.onScroll}>
|
||||
${groupChatMessages(this.messages).map((group) => group.kind === "message"
|
||||
? this.renderMessage(group.message, group.index)
|
||||
: this.renderMessageGroup(group.messages, group.startIndex))}
|
||||
<div class="chat-wrap">
|
||||
${this.renderHistoryIndicator()}
|
||||
<div class="chat" @scroll=${() => { this.onScroll(); }}>
|
||||
${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>
|
||||
`;
|
||||
}
|
||||
|
||||
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) {
|
||||
return html`
|
||||
<article class="msg ${message.role}" data-index=${index}>
|
||||
@@ -52,7 +103,7 @@ export class ChatView extends LitElement {
|
||||
private renderMessageGroup(messages: ChatLine[], startIndex: number) {
|
||||
const key = this.groupKey(startIndex);
|
||||
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>
|
||||
<b class="label">events</b>
|
||||
<span>${summarizeChatGroup(messages)}</span>
|
||||
@@ -84,7 +135,8 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
|
||||
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);
|
||||
if (details.open) openGroupKeys.add(key);
|
||||
else openGroupKeys.delete(key);
|
||||
@@ -93,10 +145,20 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
|
||||
private onScroll() {
|
||||
this.updateLoadedScrollPercent();
|
||||
if (this.chat && this.chat.scrollTop < 64 && this.hasMore && !this.loadingMore) this.onLoadMore?.();
|
||||
this.pinnedToBottom = this.isNearBottom();
|
||||
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 {
|
||||
const chat = this.chat;
|
||||
if (!chat) return true;
|
||||
@@ -154,7 +216,7 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
const chatTop = chat.getBoundingClientRect().top;
|
||||
const position = {
|
||||
index: Number(firstVisible.dataset.index ?? 0),
|
||||
index: Number(firstVisible.dataset["index"] ?? 0),
|
||||
offset: firstVisible.getBoundingClientRect().top - chatTop,
|
||||
};
|
||||
localStorage.setItem(this.storageKey(sessionId), JSON.stringify(position));
|
||||
@@ -165,17 +227,17 @@ export class ChatView extends LitElement {
|
||||
|
||||
private scheduleScrollPositionSave() {
|
||||
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 {
|
||||
if (!this.sessionId) return undefined;
|
||||
if (this.sessionId === "") return undefined;
|
||||
try {
|
||||
const raw = localStorage.getItem(this.storageKey());
|
||||
if (!raw) return undefined;
|
||||
const value = JSON.parse(raw);
|
||||
if (typeof value?.index !== "number" || typeof value?.offset !== "number") return undefined;
|
||||
return { index: value.index, offset: value.offset };
|
||||
if (raw === null || raw === "") return undefined;
|
||||
const value: unknown = JSON.parse(raw);
|
||||
if (!isScrollPosition(value)) return undefined;
|
||||
return value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -192,7 +254,7 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
|
||||
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[] {
|
||||
@@ -218,14 +280,14 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
|
||||
private groupKey(startIndex: number): string {
|
||||
return `${this.sessionId}:${startIndex}`;
|
||||
return `${this.sessionId}:${String(startIndex)}`;
|
||||
}
|
||||
|
||||
private readOpenGroupKeys(): Set<string> {
|
||||
if (!this.sessionId) return new Set();
|
||||
if (this.sessionId === "") return new Set();
|
||||
try {
|
||||
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") : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
@@ -233,7 +295,7 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
|
||||
private saveOpenGroupKeys(): void {
|
||||
if (!this.sessionId) return;
|
||||
if (this.sessionId === "") return;
|
||||
try {
|
||||
localStorage.setItem(this.groupStorageKey(), JSON.stringify([...this.openGroupKeys]));
|
||||
} catch {
|
||||
@@ -241,5 +303,5 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = chatStyles;
|
||||
static override styles = chatStyles;
|
||||
}
|
||||
|
||||
@@ -5,25 +5,25 @@ import { commandPickerStyles } from "./shared";
|
||||
|
||||
@customElement("command-picker")
|
||||
export class CommandPicker extends LitElement {
|
||||
@property() title = "Select";
|
||||
@property() override title = "Select";
|
||||
@property({ attribute: false }) options: CommandOption[] = [];
|
||||
@property({ attribute: false }) onPick?: (value: string) => void;
|
||||
@property({ attribute: false }) onCancel?: () => void;
|
||||
@state() private selectedIndex = 0;
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return html`
|
||||
<div class="backdrop" @mousedown=${() => this.onCancel?.()}>
|
||||
<section @mousedown=${(event: MouseEvent) => event.stopPropagation()}>
|
||||
<section @mousedown=${(event: MouseEvent) => { event.stopPropagation(); }}>
|
||||
<header>
|
||||
<strong>${this.title}</strong>
|
||||
<button @click=${() => this.onCancel?.()}>×</button>
|
||||
</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`
|
||||
<button class=${index === this.selectedIndex ? "selected" : ""} @click=${() => this.onPick?.(option.value)}>
|
||||
<span>${option.label}</span>
|
||||
${option.description ? html`<small>${option.description}</small>` : null}
|
||||
${option.description !== undefined && option.description !== "" ? html`<small>${option.description}</small>` : null}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@ export class CommandPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
override firstUpdated() {
|
||||
this.renderRoot.querySelector<HTMLElement>(".options")?.focus();
|
||||
}
|
||||
|
||||
@@ -53,5 +53,5 @@ export class CommandPicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = commandPickerStyles;
|
||||
static override styles = commandPickerStyles;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@ export class Composer extends LitElement {
|
||||
@property({ attribute: false }) onStopSession?: () => void;
|
||||
@state() private draft = "";
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return html`
|
||||
<footer>
|
||||
<textarea
|
||||
.value=${this.draft}
|
||||
?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) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -24,7 +26,7 @@ export class Composer extends LitElement {
|
||||
}}
|
||||
placeholder="Message pi..."
|
||||
></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>
|
||||
</footer>
|
||||
`;
|
||||
@@ -32,10 +34,10 @@ export class Composer extends LitElement {
|
||||
|
||||
private send() {
|
||||
const text = this.draft.trim();
|
||||
if (!text || this.disabled) return;
|
||||
if (text === "" || this.disabled) return;
|
||||
this.draft = "";
|
||||
this.onSend?.(text);
|
||||
}
|
||||
|
||||
static styles = composerStyles;
|
||||
static override styles = composerStyles;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import { formattedTextStyles } from "./shared";
|
||||
export class FormattedText extends LitElement {
|
||||
@property() text = "";
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return html`<div class="formatted">${unsafeHTML(toSafeMarkdownHtml(this.text))}</div>`;
|
||||
}
|
||||
|
||||
static styles = formattedTextStyles;
|
||||
static override styles = formattedTextStyles;
|
||||
}
|
||||
|
||||
@@ -25,30 +25,30 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private readonly sessions = new SessionController(
|
||||
() => this.state,
|
||||
(patch) => this.setState(patch),
|
||||
() => this.updateUrl(),
|
||||
(patch) => { this.setState(patch); },
|
||||
() => { this.updateUrl(); },
|
||||
);
|
||||
private readonly workspaces = new WorkspaceController(
|
||||
() => this.state,
|
||||
(patch) => this.setState(patch),
|
||||
() => this.updateUrl(),
|
||||
(patch) => { this.setState(patch); },
|
||||
() => { this.updateUrl(); },
|
||||
this.sessions,
|
||||
);
|
||||
private readonly projects = new ProjectController(
|
||||
() => this.state,
|
||||
(patch) => this.setState(patch),
|
||||
(patch) => { this.setState(patch); },
|
||||
this.workspaces,
|
||||
);
|
||||
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
|
||||
|
||||
connectedCallback(): void {
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("popstate", this.onPopState);
|
||||
this.sessions.connectStatusUpdates();
|
||||
void this.loadProjectsAndRestoreRoute();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
override disconnectedCallback(): void {
|
||||
window.removeEventListener("popstate", this.onPopState);
|
||||
this.sessions.dispose();
|
||||
super.disconnectedCallback();
|
||||
@@ -65,7 +65,7 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private async restoreRoute(updateUrl: boolean) {
|
||||
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);
|
||||
if (!project) return;
|
||||
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;
|
||||
return html`
|
||||
<div class="shell">
|
||||
@@ -106,18 +106,18 @@ export class PiWebApp extends LitElement {
|
||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||
${state.selectedSession ? html`
|
||||
<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>
|
||||
${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>`}
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = appStyles;
|
||||
static override styles = appStyles;
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
return new Promise((resolve) => requestAnimationFrame(() => { resolve(); }));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export class ProjectList extends LitElement {
|
||||
@property({ attribute: false }) selected?: Project;
|
||||
@property({ attribute: false }) onSelect?: (project: Project) => void;
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return html`
|
||||
<section>
|
||||
<h2>Projects</h2>
|
||||
@@ -22,5 +22,5 @@ export class ProjectList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = listStyles;
|
||||
static override styles = listStyles;
|
||||
}
|
||||
|
||||
@@ -18,20 +18,20 @@ export class PromptEditor extends LitElement {
|
||||
@state() private selectedIndex = 0;
|
||||
private requestVersion = 0;
|
||||
|
||||
protected willUpdate(changed: PropertyValues<this>) {
|
||||
protected override willUpdate(changed: PropertyValues<this>) {
|
||||
if (!changed.has("sessionId")) return;
|
||||
const previousSessionId = changed.get("sessionId") as string | undefined;
|
||||
if (previousSessionId) saveDraft(previousSessionId, this.draft);
|
||||
this.draft = this.sessionId ? loadDraft(this.sessionId) : "";
|
||||
const previousSessionId = changed.get("sessionId");
|
||||
if (previousSessionId !== undefined && previousSessionId !== "") saveDraft(previousSessionId, this.draft);
|
||||
this.draft = this.sessionId !== undefined && this.sessionId !== "" ? loadDraft(this.sessionId) : "";
|
||||
this.completions = [];
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues) {
|
||||
protected override updated(changed: PropertyValues) {
|
||||
if (changed.has("draft") || changed.has("sessionId")) this.resizeTextarea();
|
||||
}
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
const inputMode = inputModeForDraft(this.draft);
|
||||
const shellMode = inputMode.kind === "shell";
|
||||
return html`
|
||||
@@ -40,14 +40,16 @@ export class PromptEditor extends LitElement {
|
||||
<textarea
|
||||
.value=${this.draft}
|
||||
?disabled=${this.disabled}
|
||||
@input=${(event: Event) => this.updateDraft((event.target as HTMLTextAreaElement).value)}
|
||||
@keydown=${(event: KeyboardEvent) => this.handleKeyDown(event)}
|
||||
@input=${(event: 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"
|
||||
></textarea>
|
||||
${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>
|
||||
<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>
|
||||
</footer>
|
||||
`;
|
||||
@@ -61,12 +63,12 @@ export class PromptEditor extends LitElement {
|
||||
const textarea = this.textarea;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
textarea.style.height = `${String(textarea.scrollHeight)}px`;
|
||||
}
|
||||
|
||||
private updateDraft(value: string) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -74,19 +76,26 @@ export class PromptEditor extends LitElement {
|
||||
const trigger = this.currentTrigger();
|
||||
const version = ++this.requestVersion;
|
||||
this.selectedIndex = 0;
|
||||
if (!trigger) {
|
||||
if (trigger === undefined) {
|
||||
this.completions = [];
|
||||
return;
|
||||
}
|
||||
if (trigger.kind === "command" && this.sessionId) {
|
||||
const commands = await api.commands(this.sessionId).catch(() => [] as SlashCommand[]);
|
||||
if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") {
|
||||
const commands = await api.commands(this.sessionId).catch(emptySlashCommands);
|
||||
if (version !== this.requestVersion) return;
|
||||
this.completions = commands
|
||||
.filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase()))
|
||||
.slice(0, 12)
|
||||
.map((command) => ({ kind: "command", replaceFrom: trigger.from, replaceTo: this.draft.length, insertText: `/${command.name}`, detail: command.source, description: command.description }));
|
||||
} else if (trigger.kind === "file" && this.cwd) {
|
||||
const files = await api.files(this.cwd, trigger.query, trigger.fileKind).catch(() => [] as FileSuggestion[]);
|
||||
.map((command) => ({
|
||||
kind: "command",
|
||||
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;
|
||||
this.completions = files
|
||||
.slice(0, 12)
|
||||
@@ -119,7 +128,8 @@ export class PromptEditor extends LitElement {
|
||||
}
|
||||
if (event.key === "Tab" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
this.pick(this.completions[this.selectedIndex]);
|
||||
const completion = this.completions[this.selectedIndex];
|
||||
if (completion !== undefined) this.pick(completion);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
@@ -136,20 +146,28 @@ export class PromptEditor extends LitElement {
|
||||
|
||||
private pick(item: CompletionItem) {
|
||||
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 = [];
|
||||
}
|
||||
|
||||
private send() {
|
||||
const text = this.draft.trim();
|
||||
if (!text || this.disabled) return;
|
||||
if (text === "" || this.disabled) return;
|
||||
this.draft = "";
|
||||
if (this.sessionId) clearDraft(this.sessionId);
|
||||
if (this.sessionId !== undefined && this.sessionId !== "") clearDraft(this.sessionId);
|
||||
this.completions = [];
|
||||
this.onSend?.(text);
|
||||
}
|
||||
|
||||
static styles = promptEditorStyles;
|
||||
static override styles = promptEditorStyles;
|
||||
}
|
||||
|
||||
function emptySlashCommands(): SlashCommand[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function emptyFileSuggestions(): FileSuggestion[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
const draftStoragePrefix = "pi-web:prompt-draft:";
|
||||
|
||||
@@ -3,6 +3,11 @@ import { customElement, property } from "lit/decorators.js";
|
||||
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
|
||||
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")
|
||||
export class SessionList extends LitElement {
|
||||
@property({ attribute: false }) sessions: SessionInfo[] = [];
|
||||
@@ -13,13 +18,13 @@ export class SessionList extends LitElement {
|
||||
@property({ attribute: false }) onSelect?: (session: SessionInfo) => void;
|
||||
@property({ attribute: false }) onStart?: () => void;
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return html`
|
||||
<section>
|
||||
<h2>Sessions <button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button></h2>
|
||||
${this.sessions.map((session) => html`
|
||||
<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>
|
||||
`)}
|
||||
</section>
|
||||
@@ -30,13 +35,13 @@ export class SessionList extends LitElement {
|
||||
const status = this.statuses[session.id];
|
||||
const activity = this.activities[session.id];
|
||||
if (activity?.phase === "active") return `● ${activity.label} · `;
|
||||
if (!status) return "";
|
||||
if (status === undefined) return "";
|
||||
if (status.isStreaming) return "● streaming · ";
|
||||
if (status.isBashRunning) return "● bash · ";
|
||||
if (status.isCompacting) return "● compacting · ";
|
||||
if (status.pendingMessageCount) return `● ${status.pendingMessageCount} pending · `;
|
||||
if (status.pendingMessageCount > 0) return `● ${String(status.pendingMessageCount)} pending · `;
|
||||
return "";
|
||||
}
|
||||
|
||||
static styles = listStyles;
|
||||
static override styles = listStyles;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ export class StatusBar extends LitElement {
|
||||
@property({ attribute: false }) activity?: SessionActivity;
|
||||
@property({ attribute: false }) workspace?: Workspace;
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
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 provider = status.model?.provider ? `${status.model.provider}/` : "";
|
||||
const state = status.isCompacting ? "compacting" : status.isBashRunning ? "bash" : status.isStreaming ? "running" : status.pendingMessageCount ? "queued" : "idle";
|
||||
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 > 0 ? "queued" : "idle";
|
||||
const active = state !== "idle" || this.activity?.phase === "active";
|
||||
const context = status.contextUsage;
|
||||
const contextText = context
|
||||
@@ -34,17 +34,17 @@ export class StatusBar extends LitElement {
|
||||
<span>↓${formatTokenCount(tokens.output)}</span>
|
||||
<span>${contextText}</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>
|
||||
`;
|
||||
}
|
||||
|
||||
private activityText(state: string): string {
|
||||
const activity = this.activity;
|
||||
if (!activity) return state;
|
||||
if (activity === undefined) 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;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export class WorkspaceList extends LitElement {
|
||||
@property({ attribute: false }) selected?: Workspace;
|
||||
@property({ attribute: false }) onSelect?: (workspace: Workspace) => void;
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return html`
|
||||
<section>
|
||||
<h2>Workspaces</h2>
|
||||
@@ -22,5 +22,5 @@ export class WorkspaceList extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = listStyles;
|
||||
static override styles = listStyles;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@ export const listStyles = css`
|
||||
|
||||
export const chatStyles = css`
|
||||
: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; }
|
||||
.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.user { border-color: #2f81f7; background: #0d2847; }
|
||||
.msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; }
|
||||
@@ -64,6 +66,8 @@ export const chatStyles = css`
|
||||
.group-msg.tool { color: #d29922; }
|
||||
.group-msg.system { color: #ff7b72; }
|
||||
.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; }
|
||||
formatted-text.part { display: block; }
|
||||
.part + .part { margin-top: 10px; }
|
||||
|
||||
@@ -16,7 +16,7 @@ export class ProjectController {
|
||||
|
||||
async addProject() {
|
||||
const path = prompt("Project folder path");
|
||||
if (!path) return;
|
||||
if (path === null || path === "") return;
|
||||
try {
|
||||
const project = await api.addProject(path);
|
||||
const projects = this.getState().projects;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { api, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus } from "../api";
|
||||
|
||||
const MESSAGE_PAGE_SIZE = 100;
|
||||
import { normalizeMessages, textMessage } from "../chatMessages";
|
||||
import { applyTranscriptEvent } from "../chatTranscript";
|
||||
import { isShellInput } from "../inputModes";
|
||||
@@ -25,7 +27,7 @@ export class SessionController {
|
||||
|
||||
clearActiveSession() {
|
||||
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() {
|
||||
@@ -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();
|
||||
try {
|
||||
const buffered: SessionUiEvent[] = [];
|
||||
this.socket.connect(session.id, (event) => buffered.push(event));
|
||||
const [messages, status] = await Promise.all([api.messages(session.id), api.status(session.id)]);
|
||||
this.setState({ selectedSession: session, messages: normalizeMessages(messages), status });
|
||||
const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]);
|
||||
this.setState({ selectedSession: session, messages: normalizeMessages(page.messages), messagePageStart: page.start, messagePageTotal: page.total, isLoadingEarlierMessages: false, status });
|
||||
this.applyStatus(status);
|
||||
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();
|
||||
} catch (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) {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("/")) return this.runCommand(text);
|
||||
@@ -126,7 +148,7 @@ export class SessionController {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
const current = this.getState().selectedSession;
|
||||
const sessions = [result.session, ...this.getState().sessions.filter((session) => session.id !== result.session?.id)];
|
||||
|
||||
@@ -5,7 +5,7 @@ export type SetState = (patch: Partial<AppState>) => void;
|
||||
export type UpdateUrl = () => void;
|
||||
|
||||
export interface RouteTarget {
|
||||
workspaceId?: string;
|
||||
sessionId?: string;
|
||||
updateUrl?: boolean;
|
||||
workspaceId?: string | undefined;
|
||||
sessionId?: string | undefined;
|
||||
updateUrl?: boolean | undefined;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export class WorkspaceController {
|
||||
try {
|
||||
const workspaces = await api.workspaces(project.id);
|
||||
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 });
|
||||
else if (target?.updateUrl !== false) this.updateUrl();
|
||||
} 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.setState({ selectedWorkspace: workspace, sessions: [], error: "" });
|
||||
try {
|
||||
const sessions = await api.sessions(workspace.path);
|
||||
this.setState({ sessions });
|
||||
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 });
|
||||
else if (target?.updateUrl !== false) this.updateUrl();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { marked } from "marked";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ function escapeHtml(text: string): string {
|
||||
function sanitizeHtml(html: string): string {
|
||||
const template = document.createElement("template");
|
||||
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) => {
|
||||
for (const attribute of [...element.attributes]) {
|
||||
const name = attribute.name.toLowerCase();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface AppRoute {
|
||||
projectId?: string;
|
||||
workspaceId?: string;
|
||||
sessionId?: string;
|
||||
projectId: string | undefined;
|
||||
workspaceId: string | undefined;
|
||||
sessionId: string | undefined;
|
||||
}
|
||||
|
||||
export function readRoute(): AppRoute {
|
||||
@@ -18,9 +18,9 @@ export function writeRoute(route: AppRoute): void {
|
||||
url.searchParams.delete("project");
|
||||
url.searchParams.delete("workspace");
|
||||
url.searchParams.delete("session");
|
||||
if (route.projectId) url.searchParams.set("project", route.projectId);
|
||||
if (route.workspaceId) url.searchParams.set("workspace", route.workspaceId);
|
||||
if (route.sessionId) url.searchParams.set("session", route.sessionId);
|
||||
if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId);
|
||||
if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId);
|
||||
if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId);
|
||||
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (next !== current) window.history.pushState({}, "", url);
|
||||
|
||||
@@ -13,9 +13,9 @@ export type SessionUiEvent =
|
||||
| { type: "session.error"; message: string };
|
||||
|
||||
export class SessionSocket {
|
||||
private socket?: WebSocket;
|
||||
private sessionId?: string;
|
||||
private onEvent?: (event: SessionUiEvent) => void;
|
||||
private socket: WebSocket | undefined;
|
||||
private sessionId: string | undefined;
|
||||
private onEvent: ((event: SessionUiEvent) => void) | undefined;
|
||||
private reconnectTimer?: number;
|
||||
private reconnectDelay = 500;
|
||||
private shouldReconnect = false;
|
||||
@@ -42,14 +42,14 @@ export class SessionSocket {
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
if (!this.sessionId || !this.shouldReconnect) return;
|
||||
if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return;
|
||||
const socket = sessionEvents(this.sessionId);
|
||||
this.socket = socket;
|
||||
socket.onopen = () => {
|
||||
this.reconnectDelay = 500;
|
||||
};
|
||||
socket.onmessage = (message) => void this.handleMessage(message.data);
|
||||
socket.onerror = () => socket.close();
|
||||
socket.onerror = () => { socket.close(); };
|
||||
socket.onclose = () => {
|
||||
if (this.socket === socket) this.socket = undefined;
|
||||
this.scheduleReconnect();
|
||||
@@ -61,7 +61,7 @@ export class SessionSocket {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
const delay = this.reconnectDelay;
|
||||
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> {
|
||||
@@ -71,8 +71,8 @@ export class SessionSocket {
|
||||
}
|
||||
|
||||
export class GlobalSessionSocket {
|
||||
private socket?: WebSocket;
|
||||
private onEvent?: (event: Extract<SessionUiEvent, { type: "status.update" | "activity.update" }>) => void;
|
||||
private socket: WebSocket | undefined;
|
||||
private onEvent: ((event: Extract<SessionUiEvent, { type: "status.update" | "activity.update" }>) => void) | undefined;
|
||||
private reconnectTimer?: number;
|
||||
private reconnectDelay = 500;
|
||||
private shouldReconnect = false;
|
||||
@@ -100,7 +100,7 @@ export class GlobalSessionSocket {
|
||||
this.reconnectDelay = 500;
|
||||
};
|
||||
socket.onmessage = (message) => void this.handleMessage(message.data);
|
||||
socket.onerror = () => socket.close();
|
||||
socket.onerror = () => { socket.close(); };
|
||||
socket.onclose = () => {
|
||||
if (this.socket === socket) this.socket = undefined;
|
||||
this.scheduleReconnect();
|
||||
@@ -112,7 +112,7 @@ export class GlobalSessionSocket {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
const delay = this.reconnectDelay;
|
||||
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> {
|
||||
@@ -121,12 +121,20 @@ export class GlobalSessionSocket {
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionUiEvent(event: any): 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);
|
||||
function isSessionUiEvent(event: unknown): event is SessionUiEvent {
|
||||
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" }> {
|
||||
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> {
|
||||
@@ -141,12 +149,12 @@ async function parseSocketEvent(data: MessageEvent["data"]): Promise<unknown> {
|
||||
}
|
||||
|
||||
function closeSocketQuietly(socket: WebSocket | undefined): void {
|
||||
if (!socket) return;
|
||||
if (socket === undefined) return;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.onclose = null;
|
||||
if (socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.onopen = () => socket.close();
|
||||
socket.onopen = () => { socket.close(); };
|
||||
return;
|
||||
}
|
||||
socket.close();
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ChatLine } from "./components/shared";
|
||||
import type { SessionUiEvent } from "./sessionSocket";
|
||||
|
||||
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[] {
|
||||
@@ -19,13 +19,13 @@ export function finalizeShellMessage(messages: ChatLine[], event: Extract<Sessio
|
||||
const lastPart = last?.parts.at(-1);
|
||||
if (last?.role !== "bash" || lastPart?.type !== "text") return messages;
|
||||
const notes: string[] = [];
|
||||
if (!lastPart.text.includes("\n\n") && !event.output) notes.push("(no output)");
|
||||
if (event.isError) notes.push(event.output ?? "Bash command failed");
|
||||
if (event.exitCode != null) notes.push(`exit ${event.exitCode}`);
|
||||
if (event.cancelled) notes.push("cancelled");
|
||||
if (event.truncated) notes.push("output truncated");
|
||||
if (event.fullOutputPath) notes.push(`full output: ${event.fullOutputPath}`);
|
||||
if (!notes.length) return messages;
|
||||
if (!lastPart.text.includes("\n\n") && (event.output === undefined || event.output === "")) notes.push("(no output)");
|
||||
if (event.isError === true) notes.push(event.output ?? "Bash command failed");
|
||||
if (event.exitCode != null) notes.push(`exit ${String(event.exitCode)}`);
|
||||
if (event.cancelled === true) notes.push("cancelled");
|
||||
if (event.truncated === true) notes.push("output truncated");
|
||||
if (event.fullOutputPath !== undefined && event.fullOutputPath !== "") notes.push(`full output: ${event.fullOutputPath}`);
|
||||
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")}` }] }];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ export function formatTokenCount(count: number): string {
|
||||
if (!Number.isFinite(count)) return "0";
|
||||
if (count < 1000) return Math.round(count).toString();
|
||||
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`;
|
||||
return `${Math.round(count / 1_000_000)}M`;
|
||||
return `${String(Math.round(count / 1_000_000))}M`;
|
||||
}
|
||||
|
||||
export function formatCost(cost: number): string {
|
||||
|
||||
Reference in New Issue
Block a user