feat(sessions): context usage progress bar with token estimation

Replace channel column with context usage bar showing estimated tokens
vs compaction threshold. Token count estimated from messages JSONB
(octet_length/4 + 12K system prompt) — no migration needed.
Color-coded: green <60%, amber 60-85%, red >85%. Inline compaction
count with refresh icon.
This commit is contained in:
viettranx committed 2026-03-10 20:32:58 +07:00
1 parent a4f2d02a80
commit fab9028b05
7 files changed
+105 -22

No files matched your search

+15 -7
View File
@@ -161,7 +161,10 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session
const richCols = `s.session_key, jsonb_array_length(s.messages), s.created_at, s.updated_at,
s.label, s.channel, s.user_id, COALESCE(s.metadata, '{}'),
s.model, s.provider, s.input_tokens, s.output_tokens,
COALESCE(a.display_name, '')`
COALESCE(a.display_name, ''),
octet_length(s.messages::text) / 4 + 12000,
COALESCE(a.context_window, 200000),
s.compaction_count`
if opts.AgentID != "" {
selectQ = `SELECT ` + richCols + `
@@ -191,8 +194,10 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session
var model, provider *string
var inputTokens, outputTokens int64
var agentName string
var estimatedTokens, contextWindow, compactionCount int
if err := rows.Scan(&key, &msgCount, &createdAt, &updatedAt, &label, &channel, &userID, &metaJSON,
&model, &provider, &inputTokens, &outputTokens, &agentName); err != nil {
&model, &provider, &inputTokens, &outputTokens, &agentName,
&estimatedTokens, &contextWindow, &compactionCount); err != nil {
continue
}
var meta map[string]string
@@ -210,11 +215,14 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session
UserID: derefStr(userID),
Metadata: meta,
},
Model: derefStr(model),
Provider: derefStr(provider),
InputTokens: inputTokens,
OutputTokens: outputTokens,
AgentName: agentName,
Model: derefStr(model),
Provider: derefStr(provider),
InputTokens: inputTokens,
OutputTokens: outputTokens,
AgentName: agentName,
EstimatedTokens: estimatedTokens,
ContextWindow: contextWindow,
CompactionCount: compactionCount,
})
}
if result == nil {
+8 -5
View File
@@ -65,11 +65,14 @@ type SessionListResult struct {
// SessionInfoRich is an enriched session info for API responses (includes model, tokens, agent name).
type SessionInfoRich struct {
SessionInfo
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
InputTokens int64 `json:"inputTokens,omitempty"`
OutputTokens int64 `json:"outputTokens,omitempty"`
AgentName string `json:"agentName,omitempty"`
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
InputTokens int64 `json:"inputTokens,omitempty"`
OutputTokens int64 `json:"outputTokens,omitempty"`
AgentName string `json:"agentName,omitempty"`
EstimatedTokens int `json:"estimatedTokens,omitempty"` // estimated current context tokens (messages bytes/4 + 12k system prompt)
ContextWindow int `json:"contextWindow,omitempty"` // agent's context window size
CompactionCount int `json:"compactionCount,omitempty"` // number of compactions performed
}
// SessionListRichResult is the paginated result of ListPagedRich.
+4 -1
View File
@@ -9,10 +9,13 @@
"columns": {
"session": "Session",
"agent": "Agent",
"channel": "Channel",
"context": "Context",
"messages": "Messages",
"updated": "Updated"
},
"contextBar": {
"compacted": "Compacted {{count}} time(s)"
},
"detail": {
"reset": "Reset",
"delete": "Delete",
+4 -1
View File
@@ -9,10 +9,13 @@
"columns": {
"session": "Session",
"agent": "Agent",
"channel": "Channel",
"context": "Ngữ cảnh",
"messages": "Tin nhắn",
"updated": "Cập nhật"
},
"contextBar": {
"compacted": "Đã nén {{count}} lần"
},
"detail": {
"reset": "Đặt lại",
"delete": "Xóa",
+4 -1
View File
@@ -9,10 +9,13 @@
"columns": {
"session": "Session",
"agent": "Agent",
"channel": "Channel",
"context": "上下文",
"messages": "消息",
"updated": "更新时间"
},
"contextBar": {
"compacted": "已压缩 {{count}} 次"
},
"detail": {
"reset": "重置",
"delete": "删除",
+67 -7
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { History } from "lucide-react";
import { History, RefreshCw } from "lucide-react";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
@@ -12,7 +12,7 @@ import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useSessions } from "./hooks/use-sessions";
import { SessionDetailPage } from "./session-detail-page";
import { parseSessionKey } from "@/lib/session-key";
import { formatRelativeTime } from "@/lib/format";
import { formatRelativeTime, formatTokens } from "@/lib/format";
import type { SessionInfo } from "@/types/session";
export function SessionsPage() {
@@ -87,12 +87,12 @@ export function SessionsPage() {
/>
) : (
<div className="rounded-md border overflow-x-auto">
<table className="w-full min-w-[600px]">
<table className="w-full min-w-[750px]">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left text-sm font-medium">{t("columns.session")}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t("columns.agent")}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t("columns.channel")}</th>
<th className="px-4 py-3 text-left text-sm font-medium">{t("columns.context")}</th>
<th className="px-4 py-3 text-right text-sm font-medium">{t("columns.messages")}</th>
<th className="px-4 py-3 text-right text-sm font-medium">{t("columns.updated")}</th>
</tr>
@@ -129,6 +129,7 @@ function SessionRow({
session: SessionInfo;
onClick: () => void;
}) {
const { t } = useTranslation("sessions");
const parsed = parseSessionKey(session.key);
return (
@@ -140,15 +141,23 @@ function SessionRow({
<div className="text-sm font-medium">
{session.metadata?.chat_title || session.metadata?.display_name || session.label || parsed.scope}
</div>
<div className="text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{session.metadata?.username ? `@${session.metadata.username}` : session.key}
{session.channel && session.channel !== "ws" && (
<Badge variant="secondary" className="text-[10px] px-1 py-0">{session.channel}</Badge>
)}
</div>
</td>
<td className="px-4 py-3">
<Badge variant="outline">{session.agentName || parsed.agentId}</Badge>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{session.channel || "ws"}
<td className="px-4 py-3">
<ContextUsageBar
estimatedTokens={session.estimatedTokens ?? 0}
contextWindow={session.contextWindow ?? 0}
compactionCount={session.compactionCount ?? 0}
t={t}
/>
</td>
<td className="px-4 py-3 text-right text-sm">{session.messageCount}</td>
<td className="px-4 py-3 text-right text-sm text-muted-foreground">
@@ -157,3 +166,54 @@ function SessionRow({
</tr>
);
}
/** Inline context usage progress bar with compaction count. */
function ContextUsageBar({
estimatedTokens,
contextWindow,
compactionCount,
t,
}: {
estimatedTokens: number;
contextWindow: number;
compactionCount: number;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
if (contextWindow <= 0) return <span className="text-xs text-muted-foreground">—</span>;
const threshold = contextWindow * 0.75;
const pct = Math.min(Math.round((estimatedTokens / threshold) * 100), 100);
let barColor = "bg-emerald-500";
if (pct >= 85) barColor = "bg-red-500";
else if (pct >= 60) barColor = "bg-amber-500";
const tooltip = `~${formatTokens(estimatedTokens)} / ${formatTokens(contextWindow)} tokens (${pct}%)`;
return (
<div className="flex items-center gap-2 min-w-[120px]">
<div className="flex-1">
<div
className="h-2 w-full rounded-full bg-muted overflow-hidden"
title={tooltip}
>
<div
className={`h-full rounded-full transition-all ${barColor}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-muted-foreground">
<span>{formatTokens(estimatedTokens)} / {formatTokens(contextWindow)}</span>
{compactionCount > 0 && (
<span
className="inline-flex items-center gap-0.5"
title={t("contextBar.compacted", { count: compactionCount })}
>
· <RefreshCw className="h-2.5 w-2.5" />{compactionCount}
</span>
)}
</div>
</div>
</div>
);
}
+3
View File
@@ -14,6 +14,9 @@ export interface SessionInfo {
userID?: string;
metadata?: Record<string, string>;
agentName?: string;
estimatedTokens?: number;
contextWindow?: number;
compactionCount?: number;
}
export interface SessionPreview {