Improve chat feedback and drafts

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 14:01:49 +02:00
parent 7f053b0fc6
commit 0b7872b479
7 changed files with 166 additions and 23 deletions
+69 -2
View File
@@ -30,16 +30,83 @@ export class ChatView extends LitElement {
render() { render() {
return html` return html`
<div class="chat" @scroll=${this.onScroll}> <div class="chat" @scroll=${this.onScroll}>
${this.messages.map((message, index) => html` ${this.groupedMessages().map((group) => group.kind === "message"
? this.renderMessage(group.message, group.index)
: this.renderMessageGroup(group.messages, group.startIndex))}
</div>
`;
}
private renderMessage(message: ChatLine, index: number) {
return html`
<article class="msg ${message.role}" data-index=${index}> <article class="msg ${message.role}" data-index=${index}>
<b class="label">${message.role}</b> <b class="label">${message.role}</b>
${message.parts.map((part) => this.renderPart(part))} ${message.parts.map((part) => this.renderPart(part))}
</article> </article>
`;
}
private renderMessageGroup(messages: ChatLine[], startIndex: number) {
return html`
<details class="msg event-group" data-index=${startIndex}>
<summary>
<b class="label">events</b>
<span>${this.groupSummary(messages)}</span>
</summary>
<div class="group-body">
${messages.map((message) => html`
<section class="group-msg ${message.role}">
<b class="label">${message.role}</b>
${message.parts.map((part) => this.renderPart(part))}
</section>
`)} `)}
</div> </div>
</details>
`; `;
} }
private groupedMessages(): Array<{ kind: "message"; message: ChatLine; index: number } | { kind: "group"; messages: ChatLine[]; startIndex: number }> {
const groups: Array<{ kind: "message"; message: ChatLine; index: number } | { kind: "group"; messages: ChatLine[]; startIndex: number }> = [];
let eventMessages: ChatLine[] = [];
let eventStartIndex = 0;
const pushEvent = (message: ChatLine, index: number) => {
if (!eventMessages.length) eventStartIndex = index;
eventMessages.push(message);
};
const flushEvents = () => {
if (!eventMessages.length) return;
groups.push({ kind: "group", messages: eventMessages, startIndex: eventStartIndex });
eventMessages = [];
};
this.messages.forEach((message, index) => {
const readableParts = message.parts.filter((part) => this.isReadablePart(message, part));
const technicalParts = message.parts.filter((part) => !this.isReadablePart(message, part));
if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts }, index);
if (readableParts.length) {
flushEvents();
groups.push({ kind: "message", message: { role: message.role, parts: readableParts }, index });
}
});
flushEvents();
return groups;
}
private isReadablePart(message: ChatLine, part: ChatPart): boolean {
return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system");
}
private groupSummary(messages: ChatLine[]): string {
const counts = messages.reduce<Record<string, number>>((acc, message) => {
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}` : ""}`;
}
private renderPart(part: ChatPart) { private renderPart(part: ChatPart) {
if (part.type === "text") return html`<formatted-text class="part" .text=${part.text}></formatted-text>`; if (part.type === "text") return html`<formatted-text class="part" .text=${part.text}></formatted-text>`;
if (part.type === "thinking") return html`<details class="part"><summary>thinking</summary><formatted-text .text=${part.text}></formatted-text></details>`; if (part.type === "thinking") return html`<details class="part"><summary>thinking</summary><formatted-text .text=${part.text}></formatted-text></details>`;
@@ -157,7 +224,7 @@ export class ChatView extends LitElement {
} }
private articles(): HTMLElement[] { private articles(): HTMLElement[] {
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>("article.msg")); return Array.from(this.renderRoot.querySelectorAll<HTMLElement>("article.msg, details.msg"));
} }
private withSuppressedScrollSave(callback: () => void) { private withSuppressedScrollSave(callback: () => void) {
+44 -1
View File
@@ -1,4 +1,4 @@
import { LitElement, html } from "lit"; import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type SlashCommand } from "../api"; import { api, type FileSuggestion, type SlashCommand } from "../api";
import { promptEditorStyles, type CompletionItem } from "./shared"; import { promptEditorStyles, type CompletionItem } from "./shared";
@@ -17,6 +17,15 @@ export class PromptEditor extends LitElement {
@state() private selectedIndex = 0; @state() private selectedIndex = 0;
private requestVersion = 0; private requestVersion = 0;
protected 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) : "";
this.completions = [];
this.selectedIndex = 0;
}
render() { render() {
return html` return html`
<footer> <footer>
@@ -42,6 +51,7 @@ export class PromptEditor extends LitElement {
private updateDraft(value: string) { private updateDraft(value: string) {
this.draft = value; this.draft = value;
if (this.sessionId) saveDraft(this.sessionId, this.draft);
void this.refreshCompletions(); void this.refreshCompletions();
} }
@@ -111,6 +121,7 @@ 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);
this.completions = []; this.completions = [];
} }
@@ -118,9 +129,41 @@ export class PromptEditor extends LitElement {
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);
this.completions = []; this.completions = [];
this.onSend?.(text); this.onSend?.(text);
} }
static styles = promptEditorStyles; static styles = promptEditorStyles;
} }
const draftStoragePrefix = "pi-web:prompt-draft:";
function draftStorageKey(sessionId: string): string {
return `${draftStoragePrefix}${sessionId}`;
}
function loadDraft(sessionId: string): string {
try {
return localStorage.getItem(draftStorageKey(sessionId)) ?? "";
} catch {
return "";
}
}
function saveDraft(sessionId: string, draft: string): void {
try {
if (draft) localStorage.setItem(draftStorageKey(sessionId), draft);
else localStorage.removeItem(draftStorageKey(sessionId));
} catch {
// Ignore localStorage quota/privacy errors.
}
}
function clearDraft(sessionId: string): void {
try {
localStorage.removeItem(draftStorageKey(sessionId));
} catch {
// Ignore localStorage quota/privacy errors.
}
}
+4 -2
View File
@@ -19,8 +19,10 @@ export class StatusBar extends LitElement {
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
? `${context.percent == null ? "?" : context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}` ? context.percent == null
: "context ?"; ? `context ${formatTokenCount(context.contextWindow)}`
: `${context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}`
: "context unknown";
const tokens = status.tokens; const tokens = status.tokens;
return html` return html`
<div class="bar"> <div class="bar">
+8 -1
View File
@@ -55,12 +55,19 @@ export const chatStyles = css`
.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; }
.msg.system { color: #ff7b72; } .msg.system { color: #ff7b72; }
.msg.event-group { padding: 0; border-color: #30363d; background: #0d1117; color: #8b949e; }
.msg.event-group > summary { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: #8b949e; }
.msg.event-group > summary .label { margin: 0; }
.group-body { padding: 0 12px 12px; }
.group-msg { padding: 10px 0; border-top: 1px solid #21262d; color: #e6edf3; }
.group-msg.tool { color: #d29922; }
.group-msg.system { color: #ff7b72; }
.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; }
.tool-line { color: #d29922; } .tool-line { color: #d29922; }
.summary { color: #8b949e; margin-left: 6px; } .summary { color: #8b949e; margin-left: 6px; }
details { border-top: 1px solid #30363d; padding-top: 8px; } .part:is(details) { border-top: 1px solid #30363d; padding-top: 8px; }
summary { cursor: pointer; color: #8b949e; } summary { cursor: pointer; color: #8b949e; }
pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; } pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
`; `;
@@ -69,10 +69,11 @@ export class SessionController {
async runCommand(text: string) { async runCommand(text: string) {
const session = this.getState().selectedSession; const session = this.getState().selectedSession;
if (!session) return; if (!session) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try { try {
this.applyCommandResult(await api.runCommand(session.id, text)); this.applyCommandResult(await api.runCommand(session.id, text));
} catch (error) { } catch (error) {
this.setState({ error: String(error) }); this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
} }
} }
@@ -112,9 +113,10 @@ export class SessionController {
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) 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 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)];
this.setState({ sessions }); this.setState({ sessions, selectedSession: current?.id === result.session.id ? result.session : current });
void this.selectSession(result.session); if (current?.id !== result.session.id) void this.selectSession(result.session);
} }
} }
@@ -144,6 +146,8 @@ export class SessionController {
this.applyStatus(event.status); this.applyStatus(event.status);
} else if (event.type === "activity.update") { } else if (event.type === "activity.update") {
this.applyActivity(event.activity); this.applyActivity(event.activity);
} else if (event.type === "command.output") {
this.setState({ messages: [...messages, textMessage(event.level === "error" ? "system" : "tool", event.message)] });
} else if (event.type === "session.error") { } else if (event.type === "session.error") {
this.setState({ messages: [...messages, textMessage("system", event.message)] }); this.setState({ messages: [...messages, textMessage("system", event.message)] });
} }
+2 -1
View File
@@ -6,6 +6,7 @@ export type SessionUiEvent =
| { type: "tool.end"; toolName: string; isError: boolean } | { type: "tool.end"; toolName: string; isError: boolean }
| { type: "status.update"; status: SessionStatus } | { type: "status.update"; status: SessionStatus }
| { type: "activity.update"; activity: SessionActivity } | { type: "activity.update"; activity: SessionActivity }
| { type: "command.output"; level: "info" | "success" | "error"; message: string }
| { type: "session.error"; message: string }; | { type: "session.error"; message: string };
export class SessionSocket { export class SessionSocket {
@@ -118,7 +119,7 @@ export class GlobalSessionSocket {
} }
function isSessionUiEvent(event: any): event is SessionUiEvent { function isSessionUiEvent(event: any): event is SessionUiEvent {
return ["assistant.delta", "tool.start", "tool.end", "status.update", "activity.update", "session.error"].includes(event?.type); return ["assistant.delta", "tool.start", "tool.end", "status.update", "activity.update", "command.output", "session.error"].includes(event?.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" }> {
+26 -7
View File
@@ -34,7 +34,7 @@ export class SessionCommandService {
} }
if (name === "session") return { type: "done", message: formatSessionStats(session) }; if (name === "session") return { type: "done", message: formatSessionStats(session) };
if (name === "name") return this.nameSession(session, rest); if (name === "name") return this.nameSession(active, rest);
if (name === "compact") return this.compact(session, rest); if (name === "compact") return this.compact(session, rest);
if (name === "clone") return this.clone(active); if (name === "clone") return this.clone(active);
if (name === "fork") return this.fork(active); if (name === "fork") return this.fork(active);
@@ -56,17 +56,27 @@ export class SessionCommandService {
return { type: "unsupported", message: "Unsupported command response" }; return { type: "unsupported", message: "Unsupported command response" };
} }
private nameSession(session: AgentSession, 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>" };
session.setSessionName(name); active.runtime.session.setSessionName(name);
return { type: "done", message: `Session named ${name}` }; 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).catch((error) => { void session.compact(instructions || undefined)
this.events.publish(session.sessionId, { type: "session.error", message: error instanceof Error ? error.message : String(error) }); .then((result) => {
this.events.publish(session.sessionId, {
type: "command.output",
level: "success",
message: formatCompactionResult(result),
}); });
return { type: "done", message: "Compaction started" }; })
.catch((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: "session.error", message });
});
return { type: "done", message: "Compaction started…" };
} }
private async clone(active: ActiveSession): Promise<ClientCommandResult> { private async clone(active: ActiveSession): Promise<ClientCommandResult> {
@@ -122,6 +132,15 @@ function formatSessionStats(session: AgentSession): string {
].join("\n"); ].join("\n");
} }
function formatCompactionResult(result: { summary: string; tokensBefore: number }): string {
return [
"Compaction complete.",
`Tokens before: ${result.tokensBefore}`,
"",
result.summary,
].join("\n");
}
function truncate(text: string, maxLength: number): string { function truncate(text: string, maxLength: number): string {
const singleLine = text.replace(/\s+/g, " ").trim(); const singleLine = text.replace(/\s+/g, " ").trim();
return singleLine.length <= maxLength ? singleLine : `${singleLine.slice(0, maxLength - 1)}`; return singleLine.length <= maxLength ? singleLine : `${singleLine.slice(0, maxLength - 1)}`;