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;