From 1bbad2e89f6ced8599a15c372c3cff9f6fad3286 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 8 May 2026 14:53:06 +0200 Subject: [PATCH] Add session activity dock and auto naming --- src/client/src/components/ChatView.ts | 33 ++++++++++ src/client/src/components/PiWebApp.ts | 4 +- src/client/src/components/StatusBar.ts | 13 +--- src/client/src/components/shared.ts | 8 ++- .../src/controllers/sessionController.ts | 20 +++++- src/client/src/sessionSocket.ts | 4 +- src/server/sessions/piSessionService.ts | 24 +++++++ src/server/sessions/sessionNameGenerator.ts | 66 +++++++++++++++++++ src/shared/apiTypes.ts | 3 +- 9 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 src/server/sessions/sessionNameGenerator.ts diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 6e41ecf..009439b 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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()} + ${this.renderActivityDock()} + + `; + } + + private renderActivityDock() { + const state = this.activityState(); + if (state === undefined) return null; + const active = state !== "idle" || this.activity?.phase === "active"; + return html` +
+ + ${this.activityText(state)}
`; } @@ -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; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 76ec104..960320a 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -217,9 +217,9 @@ export class PiWebApp extends LitElement { ${state.error ? html`
${state.error}
` : null} ${state.selectedSession ? html` - 0} .loadingMore=${state.isLoadingEarlierMessages} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> + 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())}> this.sessions.send(text, streamingBehavior)} .onStop=${() => this.sessions.stopActiveWork()}> - + ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ` : html`
Select or start a session.
`}
${this.renderWorkspacePanel()}
diff --git a/src/client/src/components/StatusBar.ts b/src/client/src/components/StatusBar.ts index 8b7f6b6..c25e36e 100644 --- a/src/client/src/components/StatusBar.ts +++ b/src/client/src/components/StatusBar.ts @@ -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`
No session status yet
`; 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`
${this.workspace?.label ?? "workspace"} - ${this.activityText(state)} ${provider}${model} thinking ${status.thinkingLevel ?? "off"} ↑${formatTokenCount(tokens.input)} @@ -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; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 28904bd..04943b2 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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` diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 9e40b86..37a25f0 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -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); } } } diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts index 55c35c0..ef3d441 100644 --- a/src/client/src/sessionSocket.ts +++ b/src/client/src/sessionSocket.ts @@ -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 { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index f6c6318..c8a4003 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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 { 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; diff --git a/src/server/sessions/sessionNameGenerator.ts b/src/server/sessions/sessionNameGenerator.ts new file mode 100644 index 0000000..e81d56f --- /dev/null +++ b/src/server/sessions/sessionNameGenerator.ts @@ -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(modelRegistry: ModelRegistry, model: Model, firstMessage: string): Promise { + 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)}…`; +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index d087a7e..d198af7 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -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; +export type GlobalSessionEvent = Extract;