Archived
Improve chat feedback and drafts
This commit is contained in:
@@ -30,16 +30,83 @@ export class ChatView extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<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}>
|
||||
<b class="label">${message.role}</b>
|
||||
${message.parts.map((part) => this.renderPart(part))}
|
||||
</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>
|
||||
</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) {
|
||||
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>`;
|
||||
@@ -157,7 +224,7 @@ export class ChatView extends LitElement {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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 { api, type FileSuggestion, type SlashCommand } from "../api";
|
||||
import { promptEditorStyles, type CompletionItem } from "./shared";
|
||||
@@ -17,6 +17,15 @@ export class PromptEditor extends LitElement {
|
||||
@state() private selectedIndex = 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() {
|
||||
return html`
|
||||
<footer>
|
||||
@@ -42,6 +51,7 @@ export class PromptEditor extends LitElement {
|
||||
|
||||
private updateDraft(value: string) {
|
||||
this.draft = value;
|
||||
if (this.sessionId) saveDraft(this.sessionId, this.draft);
|
||||
void this.refreshCompletions();
|
||||
}
|
||||
|
||||
@@ -111,6 +121,7 @@ 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);
|
||||
this.completions = [];
|
||||
}
|
||||
|
||||
@@ -118,9 +129,41 @@ export class PromptEditor extends LitElement {
|
||||
const text = this.draft.trim();
|
||||
if (!text || this.disabled) return;
|
||||
this.draft = "";
|
||||
if (this.sessionId) clearDraft(this.sessionId);
|
||||
this.completions = [];
|
||||
this.onSend?.(text);
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ export class StatusBar extends LitElement {
|
||||
const active = state !== "idle" || this.activity?.phase === "active";
|
||||
const context = status.contextUsage;
|
||||
const contextText = context
|
||||
? `${context.percent == null ? "?" : context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}`
|
||||
: "context ?";
|
||||
? context.percent == null
|
||||
? `context ${formatTokenCount(context.contextWindow)}`
|
||||
: `${context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}`
|
||||
: "context unknown";
|
||||
const tokens = status.tokens;
|
||||
return html`
|
||||
<div class="bar">
|
||||
|
||||
@@ -55,12 +55,19 @@ export const chatStyles = css`
|
||||
.msg.user { border-color: #2f81f7; background: #0d2847; }
|
||||
.msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; }
|
||||
.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; }
|
||||
formatted-text.part { display: block; }
|
||||
.part + .part { margin-top: 10px; }
|
||||
.tool-line { color: #d29922; }
|
||||
.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; }
|
||||
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) {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session) return;
|
||||
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
|
||||
try {
|
||||
this.applyCommandResult(await api.runCommand(session.id, text));
|
||||
} 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;
|
||||
if (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)];
|
||||
this.setState({ sessions });
|
||||
void this.selectSession(result.session);
|
||||
this.setState({ sessions, selectedSession: current?.id === result.session.id ? result.session : current });
|
||||
if (current?.id !== result.session.id) void this.selectSession(result.session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +146,8 @@ export class SessionController {
|
||||
this.applyStatus(event.status);
|
||||
} else if (event.type === "activity.update") {
|
||||
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") {
|
||||
this.setState({ messages: [...messages, textMessage("system", event.message)] });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type SessionUiEvent =
|
||||
| { type: "tool.end"; toolName: string; isError: boolean }
|
||||
| { type: "status.update"; status: SessionStatus }
|
||||
| { type: "activity.update"; activity: SessionActivity }
|
||||
| { type: "command.output"; level: "info" | "success" | "error"; message: string }
|
||||
| { type: "session.error"; message: string };
|
||||
|
||||
export class SessionSocket {
|
||||
@@ -118,7 +119,7 @@ export class GlobalSessionSocket {
|
||||
}
|
||||
|
||||
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" }> {
|
||||
|
||||
@@ -34,7 +34,7 @@ export class SessionCommandService {
|
||||
}
|
||||
|
||||
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 === "clone") return this.clone(active);
|
||||
if (name === "fork") return this.fork(active);
|
||||
@@ -56,17 +56,27 @@ export class SessionCommandService {
|
||||
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>" };
|
||||
session.setSessionName(name);
|
||||
return { type: "done", message: `Session named ${name}` };
|
||||
active.runtime.session.setSessionName(name);
|
||||
return { type: "done", message: `Session named: ${name}`, session: clientSessionFromRuntime(active.runtime) };
|
||||
}
|
||||
|
||||
private compact(session: AgentSession, instructions: string): ClientCommandResult {
|
||||
void session.compact(instructions || undefined).catch((error) => {
|
||||
this.events.publish(session.sessionId, { type: "session.error", message: error instanceof Error ? error.message : String(error) });
|
||||
void session.compact(instructions || undefined)
|
||||
.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> {
|
||||
@@ -122,6 +132,15 @@ function formatSessionStats(session: AgentSession): string {
|
||||
].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 {
|
||||
const singleLine = text.replace(/\s+/g, " ").trim();
|
||||
return singleLine.length <= maxLength ? singleLine : `${singleLine.slice(0, maxLength - 1)}…`;
|
||||
|
||||
Reference in New Issue
Block a user