From c5dc6550cc7567770c3b0d83f2f045a4d82aeeb7 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 19 May 2026 09:20:35 +0200 Subject: [PATCH] feat: add conversation position meter --- .changeset/conversation-position-meter.md | 5 ++ src/client/src/components/ChatView.ts | 69 +++++++++++++++---- .../src/components/ConversationMeter.ts | 45 ++++++++++++ src/client/src/components/shared.ts | 11 ++- 4 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 .changeset/conversation-position-meter.md create mode 100644 src/client/src/components/ConversationMeter.ts diff --git a/.changeset/conversation-position-meter.md b/.changeset/conversation-position-meter.md new file mode 100644 index 0000000..0c60e65 --- /dev/null +++ b/.changeset/conversation-position-meter.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Replace the chat history banner with a count-based conversation position meter that shows approximate message position without extra requests. diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 8a4d9b1..8d26bf1 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -7,6 +7,7 @@ import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; import type { SessionActivity, SessionStatus } from "../api"; import type { ChatLine, ChatPart } from "./shared"; import { chatStyles } from "./shared"; +import "./ConversationMeter"; import "./FormattedText"; import "./ToolExecutionView"; @@ -34,6 +35,15 @@ function isScrollPosition(value: unknown): value is { index?: number; key?: stri && (("key" in value && typeof value.key === "string") || ("index" in value && typeof value.index === "number")); } +function clampPercent(value: number): number { + return clampNumber(value, 0, 100); +} + +function clampNumber(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, value)); +} + @customElement("chat-view") export class ChatView extends LitElement { @property({ attribute: false }) messages: ChatLine[] = []; @@ -53,11 +63,13 @@ export class ChatView extends LitElement { @state() private openGroupKeys = new Set(); @state() private expandedMetaKey: string | undefined; @state() private copiedMessageKey: string | undefined; + @state() private currentConversationIndex: number | undefined; private suppressScrollSave = false; private suppressLoadMoreRequests = false; private saveScrollTimer?: number; private loadMoreCheckFrame: number | undefined; private scrollToBottomFrame: number | undefined; + private conversationRailFrame: number | undefined; private groupedMessagesInput?: ChatLine[]; private groupedMessagesStart = 0; private groupedMessagesCache: ChatGroup[] = []; @@ -87,6 +99,7 @@ export class ChatView extends LitElement { window.clearTimeout(this.saveScrollTimer); if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame); if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame); + if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame); window.removeEventListener("resize", this.onViewportResize); window.visualViewport?.removeEventListener("resize", this.onViewportResize); super.disconnectedCallback(); @@ -108,6 +121,7 @@ export class ChatView extends LitElement { if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false; if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false; if (!changed.has("sessionId") && changed.has("messages") && this.pinnedToBottom) this.scrollToBottom(); + if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate(); if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded(); } @@ -115,7 +129,7 @@ export class ChatView extends LitElement { const groups = this.groupedMessages(); return html`
- ${this.renderHistoryIndicator()} + ${this.renderConversationRail()}
{ this.onScroll(); }} @wheel=${(event: WheelEvent) => { this.onWheel(event); }} @touchstart=${(event: TouchEvent) => { this.onTouchStart(event); }} @touchmove=${(event: TouchEvent) => { this.onTouchMove(event); }}> ${this.renderHistoryBoundary()} ${repeat( @@ -226,19 +240,24 @@ export class ChatView extends LitElement { return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label; } - private renderHistoryIndicator() { + private renderConversationRail() { if (!this.messages.length || this.messageTotal <= 0) return null; - const loadedCount = this.messages.length; - const loadedPercent = Math.min(100, Math.round((loadedCount / this.messageTotal) * 100)); - const olderCount = this.messageStart; - const fullHistory = olderCount <= 0 - ? "full history loaded" - : `${String(olderCount)} older not loaded ยท ${String(loadedPercent)}% loaded`; - return html` -
-
${fullHistory}
-
- `; + const total = this.conversationDisplayTotal(); + const position = this.conversationPositionPercent(total); + const loadedPercent = this.hasMore ? clampPercent((this.messages.length / total) * 100) : 100; + return html``; + } + + private conversationDisplayTotal(): number { + if (!this.hasMore && this.messageStart === 0) return Math.max(1, this.messages.length); + return Math.max(1, this.messageTotal, this.messageStart + this.messages.length); + } + + private conversationPositionPercent(total = this.conversationDisplayTotal()): number { + if (total <= 1) return 100; + const fallbackIndex = this.pinnedToBottom ? this.messageStart + this.messages.length - 1 : this.messageStart; + const index = clampNumber(this.currentConversationIndex ?? fallbackIndex, 0, total - 1); + return clampPercent((index / (total - 1)) * 100); } private renderHistoryBoundary() { @@ -449,6 +468,7 @@ export class ChatView extends LitElement { private onScroll() { this.requestLoadMoreIfNeeded(); this.updatePinnedToBottomFromScroll(); + this.scheduleConversationRailUpdate(); if (!this.suppressScrollSave) this.scheduleScrollPositionSave(); } @@ -646,6 +666,29 @@ export class ChatView extends LitElement { this.saveScrollTimer = window.setTimeout(() => { this.saveScrollPosition(); }, 180); } + private scheduleConversationRailUpdate(): void { + if (this.conversationRailFrame !== undefined) return; + this.conversationRailFrame = requestAnimationFrame(() => { + this.conversationRailFrame = undefined; + this.updateConversationRailPosition(); + }); + } + + private updateConversationRailPosition(): void { + if (!this.messages.length || this.messageTotal <= 0) { + this.currentConversationIndex = undefined; + return; + } + const total = this.conversationDisplayTotal(); + const article = this.firstVisibleArticle(); + const index = Number(article?.dataset["index"]); + if (Number.isFinite(index)) { + this.currentConversationIndex = clampNumber(index, 0, Math.max(0, total - 1)); + return; + } + this.currentConversationIndex = clampNumber(this.pinnedToBottom ? this.messageStart + this.messages.length - 1 : this.messageStart, 0, Math.max(0, total - 1)); + } + private readStoredScrollPosition(): { index?: number; key?: string; offset: number } | undefined { if (this.sessionId === "") return undefined; try { diff --git a/src/client/src/components/ConversationMeter.ts b/src/client/src/components/ConversationMeter.ts new file mode 100644 index 0000000..cd78123 --- /dev/null +++ b/src/client/src/components/ConversationMeter.ts @@ -0,0 +1,45 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("conversation-meter") +export class ConversationMeter extends LitElement { + @property({ type: Number }) positionPercent = 0; + @property({ type: Number }) loadedPercent = 100; + + override render() { + const position = clampPercent(this.positionPercent); + const loaded = clampPercent(this.loadedPercent); + const label = `Message position: about ${String(Math.round(position))}% through conversation. ${String(Math.round(loaded))}% of messages loaded.`; + return html` +
+ +
+ `; + } + + static override styles = css` + :host { position: absolute; top: -4px; left: 16px; right: 16px; z-index: 6; display: block; height: 12px; opacity: .58; transition: opacity .15s ease; } + :host(:hover), :host(:focus-within) { opacity: .92; } + .meter { height: 100%; } + .track { position: relative; height: 4px; margin-top: 4px; border-radius: 999px; background: color-mix(in srgb, var(--pi-border-muted) 34%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--pi-bg) 55%, transparent); } + .progress { position: absolute; left: 0; width: var(--position); top: 0; bottom: 0; border-radius: 999px; background: color-mix(in srgb, var(--pi-accent) 42%, var(--pi-border-muted)); } + .marker { position: absolute; left: var(--position); top: 50%; width: 10px; height: 10px; border: 2px solid var(--pi-bg); border-radius: 50%; background: var(--pi-accent); box-shadow: 0 2px 8px var(--pi-shadow); transform: translate(-50%, -50%); } + `; +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, value)); +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 7d41821..d60987f 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -173,9 +173,8 @@ export const listStyles = css` export const chatStyles = css` :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); 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; overflow-anchor: none; padding: 16px 16px 64px; box-sizing: border-box; } + .chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; } .scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; } - .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 var(--pi-border); border-radius: 8px; background: var(--pi-bg-overlay-soft); color: var(--pi-muted); padding: 6px 8px; font-size: 12px; text-align: right; pointer-events: none; box-shadow: 0 8px 24px var(--pi-shadow-soft); } .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 var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); } .activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); } .activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -190,7 +189,7 @@ export const chatStyles = css` .msg.skill { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); } .msg.event-group { padding: 0; border-color: var(--pi-border); background: var(--pi-bg); color: var(--pi-muted); } .msg.event-group.live { border-color: var(--pi-success-border); background: var(--pi-success-bg); } - .msg.event-group > summary { position: sticky; top: -16px; z-index: 3; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 9px 9px 0 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); color: var(--pi-muted); } + .msg.event-group > summary { position: sticky; top: -26px; z-index: 5; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 9px 9px 0 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); color: var(--pi-muted); } .msg.event-group.live > summary { border-bottom-color: var(--pi-success-border); background: var(--pi-success-bg); color: var(--pi-success); } .msg.event-group > summary .label { margin: 0; } .group-body { padding: 0 12px 12px; } @@ -199,7 +198,7 @@ export const chatStyles = css` .group-msg.tool-execution-shell { color: var(--pi-text); } .group-msg.system { color: var(--pi-danger); } .group-msg.bash { color: var(--pi-success); } - .history-boundary { display: grid; gap: 3px; justify-items: center; margin: 0 0 14px; color: var(--pi-muted); font-size: 12px; text-align: center; } + .history-boundary { position: relative; z-index: 5; display: grid; gap: 3px; justify-items: center; margin: 0 0 14px; color: var(--pi-muted); font-size: 12px; text-align: center; } .history-load-button { border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text-secondary); padding: 5px 12px; font: 12px system-ui, sans-serif; cursor: pointer; } .history-load-button:hover, .history-load-button:focus { border-color: var(--pi-accent); color: var(--pi-text-bright); } .history-load-button:disabled { cursor: default; opacity: .55; } @@ -218,12 +217,12 @@ export const chatStyles = css` .session-activity span, .session-activity small { color: var(--pi-muted); } .history-boundary small { color: var(--pi-dim); } .msg-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 22px; margin-bottom: 8px; } - .msg > .msg-header { position: sticky; top: -16px; z-index: 2; margin: -12px -12px 8px; padding: 7px 10px 6px; border-radius: 9px 9px 0 0; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-surface); box-shadow: 0 8px 18px var(--pi-shadow-soft); } + .msg > .msg-header { position: sticky; top: -26px; z-index: 4; margin: -12px -12px 8px; padding: 7px 10px 6px; border-radius: 9px 9px 0 0; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-surface); box-shadow: 0 8px 18px var(--pi-shadow-soft); } .msg.user > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-accent-border) 35%, transparent); background: var(--pi-selection-bg); } .msg.tool > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-warning-border) 35%, transparent); background: var(--pi-warning-surface); } .msg.bash > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-success) 35%, transparent); background: var(--pi-success-bg); } .msg.skill > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-purple-border) 35%, transparent); background: var(--pi-purple-surface); } - .group-msg > .msg-header { position: sticky; top: -16px; z-index: 2; margin: -10px 0 8px; padding: 7px 0 6px; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-bg); } + .group-msg > .msg-header { position: sticky; top: -26px; z-index: 4; margin: -10px 0 8px; padding: 7px 0 6px; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-bg); } .msg-header-trailing { min-width: 0; display: inline-flex; align-items: baseline; justify-content: flex-end; gap: 8px; } .msg-actions { display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; } .msg-action { display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; }