Add session activity dock and auto naming

This commit is contained in:
Federico Jaramillo Martinez
2026-05-08 14:53:06 +02:00
parent f64ea8d15d
commit 1bbad2e89f
9 changed files with 156 additions and 19 deletions
+33
View File
@@ -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;
+2 -2
View File
@@ -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 -12
View File
@@ -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;
}
+7 -1
View File
@@ -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);
}
}
}
+2 -2
View File
@@ -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 {