Archived
Add session activity dock and auto naming
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { groupChatMessages, summarizeChatGroup } from "../chatGroups";
|
||||
import type { SessionActivity, SessionStatus } from "../api";
|
||||
import type { ChatLine, ChatPart } from "./shared";
|
||||
import { chatStyles } from "./shared";
|
||||
import "./FormattedText";
|
||||
@@ -29,6 +30,8 @@ export class ChatView extends LitElement {
|
||||
@property({ type: Boolean }) loadingMore = false;
|
||||
@property({ type: Boolean }) isCompacting = false;
|
||||
@property({ type: Number }) pendingMessageCount = 0;
|
||||
@property({ attribute: false }) status?: SessionStatus;
|
||||
@property({ attribute: false }) activity?: SessionActivity;
|
||||
@property({ attribute: false }) onLoadMore?: () => void;
|
||||
@query(".chat") private chat?: HTMLDivElement;
|
||||
@state() private pinnedToBottom = true;
|
||||
@@ -64,6 +67,19 @@ export class ChatView extends LitElement {
|
||||
: this.renderMessageGroup(group.messages, group.startIndex))}
|
||||
${this.renderSessionActivity()}
|
||||
</div>
|
||||
${this.renderActivityDock()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderActivityDock() {
|
||||
const state = this.activityState();
|
||||
if (state === undefined) return null;
|
||||
const active = state !== "idle" || this.activity?.phase === "active";
|
||||
return html`
|
||||
<div class=${active ? "activity-dock active" : "activity-dock"} aria-live="polite">
|
||||
<span class="dot"></span>
|
||||
<span class="activity-text">${this.activityText(state)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -79,6 +95,23 @@ export class ChatView extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private activityState(): string | undefined {
|
||||
const status = this.status;
|
||||
if (status === undefined) return this.activity?.label;
|
||||
if (status.isCompacting) return "compacting";
|
||||
if (status.isBashRunning) return "bash";
|
||||
if (status.isStreaming) return "running";
|
||||
if (status.pendingMessageCount > 0) return "queued";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
private activityText(state: string): string {
|
||||
const activity = this.activity;
|
||||
if (activity === undefined) return state;
|
||||
if (state !== "idle" && activity.phase === "idle") return state;
|
||||
return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label;
|
||||
}
|
||||
|
||||
private renderHistoryIndicator() {
|
||||
if (!this.messages.length || this.messageTotal <= 0) return null;
|
||||
const loadedCount = this.messages.length;
|
||||
|
||||
@@ -217,9 +217,9 @@ export class PiWebApp extends LitElement {
|
||||
</div>
|
||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||
${state.selectedSession ? html`
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()}></prompt-editor>
|
||||
<status-bar .status=${state.status} .activity=${state.activity} .workspace=${state.selectedWorkspace}></status-bar>
|
||||
<status-bar .status=${state.status} .workspace=${state.selectedWorkspace}></status-bar>
|
||||
${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>`}
|
||||
<div class="mobile-panel">${this.renderWorkspacePanel()}</div>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { SessionActivity, SessionStatus, Workspace } from "../api";
|
||||
import type { SessionStatus, Workspace } from "../api";
|
||||
import { formatCost, formatTokenCount } from "../utils/format";
|
||||
import { statusBarStyles } from "./shared";
|
||||
|
||||
@customElement("status-bar")
|
||||
export class StatusBar extends LitElement {
|
||||
@property({ attribute: false }) status?: SessionStatus;
|
||||
@property({ attribute: false }) activity?: SessionActivity;
|
||||
@property({ attribute: false }) workspace?: Workspace;
|
||||
|
||||
override render() {
|
||||
@@ -15,8 +14,6 @@ export class StatusBar extends LitElement {
|
||||
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 !== 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
|
||||
? context.percent == null
|
||||
@@ -27,7 +24,6 @@ export class StatusBar extends LitElement {
|
||||
return html`
|
||||
<div class="bar">
|
||||
<span title=${this.workspace?.path ?? ""}>${this.workspace?.label ?? "workspace"}</span>
|
||||
<span class=${active ? "activity active" : "activity"}><span class="dot"></span>${this.activityText(state)}</span>
|
||||
<span>${provider}${model}</span>
|
||||
<span>thinking ${status.thinkingLevel ?? "off"}</span>
|
||||
<span>↑${formatTokenCount(tokens.input)}</span>
|
||||
@@ -39,12 +35,5 @@ export class StatusBar extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private activityText(state: string): string {
|
||||
const activity = this.activity;
|
||||
if (activity === undefined) return state;
|
||||
if (state !== "idle" && activity.phase === "idle") return state;
|
||||
return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label;
|
||||
}
|
||||
|
||||
static override styles = statusBarStyles;
|
||||
}
|
||||
|
||||
@@ -110,8 +110,13 @@ export const listStyles = css`
|
||||
export const chatStyles = css`
|
||||
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: #e6edf3; font: 14px system-ui, sans-serif; }
|
||||
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||
.chat { height: 100%; min-height: 0; overflow: auto; padding: 16px; box-sizing: border-box; }
|
||||
.chat { height: 100%; min-height: 0; overflow: auto; padding: 16px 16px 64px; 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; }
|
||||
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid #30363d; border-radius: 999px; background: #0d1117e6; color: #8b949e; padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px #0008; backdrop-filter: blur(6px); }
|
||||
.activity-dock.active { border-color: #238636; color: #3fb950; background: #0f1b12ee; }
|
||||
.activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
|
||||
.activity-dock.active .dot { animation: pulse 1s ease-in-out infinite; opacity: 1; }
|
||||
.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; }
|
||||
@@ -143,6 +148,7 @@ export const chatStyles = css`
|
||||
summary { cursor: pointer; color: #8b949e; }
|
||||
pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
|
||||
.shell-output { color: #e6edf3; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; }
|
||||
@keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } }
|
||||
`;
|
||||
|
||||
export const formattedTextStyles = css`
|
||||
|
||||
@@ -17,7 +17,8 @@ export class SessionController {
|
||||
connectStatusUpdates() {
|
||||
this.globalSocket.connect((event) => {
|
||||
if (event.type === "status.update") this.applyStatus(event.status);
|
||||
else this.applyActivity(event.activity);
|
||||
else if (event.type === "activity.update") this.applyActivity(event.activity);
|
||||
else this.applySessionName(event.sessionId, event.name);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -220,6 +221,21 @@ export class SessionController {
|
||||
});
|
||||
}
|
||||
|
||||
private applySessionName(sessionId: string, name: string | undefined) {
|
||||
const rename = (session: SessionInfo) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
const next = { ...session };
|
||||
if (name === undefined || name === "") delete next.name;
|
||||
else next.name = name;
|
||||
return next;
|
||||
};
|
||||
const selectedSession = this.getState().selectedSession;
|
||||
this.setState({
|
||||
sessions: this.getState().sessions.map(rename),
|
||||
selectedSession: selectedSession === undefined ? undefined : rename(selectedSession),
|
||||
});
|
||||
}
|
||||
|
||||
private applyEvent(event: SessionUiEvent) {
|
||||
const transcript = applyTranscriptEvent(this.getState().messages, event);
|
||||
if (transcript) {
|
||||
@@ -228,6 +244,8 @@ export class SessionController {
|
||||
this.applyStatus(event.status);
|
||||
} else if (event.type === "activity.update") {
|
||||
this.applyActivity(event.activity);
|
||||
} else if (event.type === "session.name") {
|
||||
this.applySessionName(event.sessionId, event.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +114,12 @@ export class GlobalSessionSocket {
|
||||
|
||||
function isSessionUiEvent(event: unknown): event is SessionUiEvent {
|
||||
const type = eventType(event);
|
||||
return ["assistant.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "pi.event"].includes(type);
|
||||
return ["assistant.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "pi.event"].includes(type);
|
||||
}
|
||||
|
||||
function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent {
|
||||
const type = eventType(event);
|
||||
return type === "status.update" || type === "activity.update";
|
||||
return type === "status.update" || type === "activity.update" || type === "session.name";
|
||||
}
|
||||
|
||||
function eventType(event: unknown): string {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
||||
import { SessionCommandService } from "./sessionCommandService.js";
|
||||
import { SessionArchiveStore } from "./sessionArchiveStore.js";
|
||||
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||
import { generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
|
||||
function noop(): void {
|
||||
// Intentionally empty default unsubscribe callback.
|
||||
@@ -129,6 +130,7 @@ export class PiSessionService {
|
||||
async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise<void> {
|
||||
await this.assertWritable(sessionId);
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
this.maybeGenerateSessionName(session, text);
|
||||
const behavior = session.isStreaming || session.isCompacting ? streamingBehavior ?? "followUp" : undefined;
|
||||
this.publishActivity(session, session.isCompacting ? "message queued during compaction" : behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
|
||||
void session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error: unknown) => {
|
||||
@@ -255,6 +257,28 @@ export class PiSessionService {
|
||||
this.active.set(session.sessionId, active);
|
||||
}
|
||||
|
||||
private maybeGenerateSessionName(session: AgentSession, firstMessage: string): void {
|
||||
if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting) return;
|
||||
const model = session.model;
|
||||
if (model === undefined) return;
|
||||
|
||||
void generateShortSessionName(this.modelRegistry, model, firstMessage).then((name) => {
|
||||
if (name === undefined || session.sessionName !== undefined) return;
|
||||
session.setSessionName(name);
|
||||
this.publishSessionName(session);
|
||||
}).catch(() => {
|
||||
// Session naming is best-effort and must not affect prompt handling.
|
||||
});
|
||||
}
|
||||
|
||||
private publishSessionName(session: AgentSession): void {
|
||||
const event = session.sessionName === undefined
|
||||
? { type: "session.name", sessionId: session.sessionId } as const
|
||||
: { type: "session.name", sessionId: session.sessionId, name: session.sessionName } as const;
|
||||
this.events.publish(session.sessionId, event);
|
||||
this.events.publishGlobal(event);
|
||||
}
|
||||
|
||||
private publishHeartbeats(): void {
|
||||
for (const active of this.active.values()) {
|
||||
const { session } = active.runtime;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { getApiProvider, type Api, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
||||
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const SESSION_NAME_TIMEOUT_MS = 10_000;
|
||||
const SESSION_NAME_MAX_INPUT_CHARS = 4_000;
|
||||
const SESSION_NAME_MAX_LENGTH = 60;
|
||||
|
||||
export async function generateShortSessionName<TApi extends Api>(modelRegistry: ModelRegistry, model: Model<TApi>, firstMessage: string): Promise<string | undefined> {
|
||||
const provider = getApiProvider(model.api);
|
||||
if (provider === undefined) return undefined;
|
||||
|
||||
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
||||
if (!auth.ok) return undefined;
|
||||
|
||||
const stream = provider.streamSimple(
|
||||
model,
|
||||
{
|
||||
systemPrompt: "Generate a concise title for a coding-agent chat session. Return only the title, with no quotes or punctuation wrapper.",
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: `Create a 2-6 word title for this request:\n\n${truncateInput(firstMessage)}`,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
},
|
||||
{
|
||||
temperature: 0.2,
|
||||
maxTokens: 24,
|
||||
reasoning: "minimal",
|
||||
signal: AbortSignal.timeout(SESSION_NAME_TIMEOUT_MS),
|
||||
...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
|
||||
...(auth.headers === undefined ? {} : { headers: auth.headers }),
|
||||
},
|
||||
);
|
||||
|
||||
let streamedText = "";
|
||||
let finalMessage: AssistantMessage | undefined;
|
||||
for await (const event of stream) {
|
||||
if (event.type === "text_delta") streamedText += event.delta;
|
||||
if (event.type === "done") finalMessage = event.message;
|
||||
if (event.type === "error") return undefined;
|
||||
}
|
||||
|
||||
return cleanSessionName(finalMessage === undefined ? streamedText : textFromAssistant(finalMessage));
|
||||
}
|
||||
|
||||
export function cleanSessionName(value: string): string | undefined {
|
||||
const title = (value.split("\n", 1)[0] ?? "")
|
||||
.replace(/^\s*["'`]+|["'`.]+\s*$/g, "")
|
||||
.replace(/^(title|session title)\s*:\s*/i, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, SESSION_NAME_MAX_LENGTH)
|
||||
.trim();
|
||||
return title === "" ? undefined : title;
|
||||
}
|
||||
|
||||
function textFromAssistant(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function truncateInput(value: string): string {
|
||||
return value.length <= SESSION_NAME_MAX_INPUT_CHARS ? value : `${value.slice(0, SESSION_NAME_MAX_INPUT_CHARS)}…`;
|
||||
}
|
||||
@@ -144,6 +144,7 @@ export type SessionUiEvent =
|
||||
| { type: "activity.update"; activity: SessionActivity }
|
||||
| { type: "command.output"; level: "info" | "success" | "error"; message: string }
|
||||
| { type: "session.error"; message: string }
|
||||
| { type: "session.name"; sessionId: string; name?: string }
|
||||
| { type: "pi.event"; eventType: string };
|
||||
|
||||
export type GlobalSessionEvent = Extract<SessionUiEvent, { type: "status.update" | "activity.update" }>;
|
||||
export type GlobalSessionEvent = Extract<SessionUiEvent, { type: "status.update" | "activity.update" | "session.name" }>;
|
||||
|
||||
Reference in New Issue
Block a user