Archived
Improve chat feedback and drafts
This commit is contained in:
@@ -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"
|
||||||
<article class="msg ${message.role}" data-index=${index}>
|
? this.renderMessage(group.message, group.index)
|
||||||
<b class="label">${message.role}</b>
|
: this.renderMessageGroup(group.messages, group.startIndex))}
|
||||||
${message.parts.map((part) => this.renderPart(part))}
|
|
||||||
</article>
|
|
||||||
`)}
|
|
||||||
</div>
|
</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) {
|
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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }> {
|
||||||
|
|||||||
@@ -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, {
|
||||||
return { type: "done", message: "Compaction started" };
|
type: "command.output",
|
||||||
|
level: "success",
|
||||||
|
message: formatCompactionResult(result),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.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)}…`;
|
||||||
|
|||||||
Reference in New Issue
Block a user