feat(ui): editable session title, detail page spacing, and session list enhancements

This commit is contained in:
viettranx committed 2026-03-08 23:14:35 +07:00
1 parent faf66cf453
commit 2a61562503
8 files changed
+169 -11

No files matched your search

+1 -1
View File
@@ -42,7 +42,7 @@ func (m *SessionsMethods) handleList(_ context.Context, client *gateway.Client,
params.Limit = 20
}
result := m.sessions.ListPaged(store.SessionListOpts{
result := m.sessions.ListPagedRich(store.SessionListOpts{
AgentID: params.AgentID,
Limit: params.Limit,
Offset: params.Offset,
+95
View File
@@ -134,6 +134,101 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList
return store.SessionListResult{Sessions: result, Total: total}
}
// ListPagedRich returns enriched session info for API responses (includes model, tokens, agent name).
func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.SessionListRichResult {
limit := opts.Limit
if limit <= 0 {
limit = 20
}
offset := opts.Offset
if offset < 0 {
offset = 0
}
var where string
var whereArgs []interface{}
if opts.AgentID != "" {
where = " WHERE s.session_key LIKE $1"
whereArgs = append(whereArgs, "agent:"+opts.AgentID+":%")
}
// Count total
var total int
countQ := "SELECT COUNT(*) FROM sessions s" + where
if err := s.db.QueryRow(countQ, whereArgs...).Scan(&total); err != nil {
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: 0}
}
// Fetch page with agent name via LEFT JOIN
var selectQ string
var selectArgs []interface{}
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, '')`
if opts.AgentID != "" {
selectQ = `SELECT ` + richCols + `
FROM sessions s LEFT JOIN agents a ON s.agent_id = a.id
WHERE s.session_key LIKE $1 ORDER BY s.updated_at DESC LIMIT $2 OFFSET $3`
selectArgs = []interface{}{whereArgs[0], limit, offset}
} else {
selectQ = `SELECT ` + richCols + `
FROM sessions s LEFT JOIN agents a ON s.agent_id = a.id
ORDER BY s.updated_at DESC LIMIT $1 OFFSET $2`
selectArgs = []interface{}{limit, offset}
}
rows, err := s.db.Query(selectQ, selectArgs...)
if err != nil {
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: total}
}
defer rows.Close()
var result []store.SessionInfoRich
for rows.Next() {
var key string
var msgCount int
var createdAt, updatedAt time.Time
var label, channel, userID *string
var metaJSON []byte
var model, provider *string
var inputTokens, outputTokens int64
var agentName string
if err := rows.Scan(&key, &msgCount, &createdAt, &updatedAt, &label, &channel, &userID, &metaJSON,
&model, &provider, &inputTokens, &outputTokens, &agentName); err != nil {
continue
}
var meta map[string]string
if len(metaJSON) > 0 {
json.Unmarshal(metaJSON, &meta)
}
result = append(result, store.SessionInfoRich{
SessionInfo: store.SessionInfo{
Key: key,
MessageCount: msgCount,
Created: createdAt,
Updated: updatedAt,
Label: derefStr(label),
Channel: derefStr(channel),
UserID: derefStr(userID),
Metadata: meta,
},
Model: derefStr(model),
Provider: derefStr(provider),
InputTokens: inputTokens,
OutputTokens: outputTokens,
AgentName: agentName,
})
}
if result == nil {
result = []store.SessionInfoRich{}
}
return store.SessionListRichResult{Sessions: result, Total: total}
}
func (s *PGSessionStore) Save(key string) error {
s.mu.RLock()
data, ok := s.cache[key]
+17
View File
@@ -62,6 +62,22 @@ type SessionListResult struct {
Total int `json:"total"`
}
// 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"`
}
// SessionListRichResult is the paginated result of ListPagedRich.
type SessionListRichResult struct {
Sessions []SessionInfoRich `json:"sessions"`
Total int `json:"total"`
}
// SessionStore manages conversation sessions.
type SessionStore interface {
GetOrCreate(key string) *SessionData
@@ -88,6 +104,7 @@ type SessionStore interface {
Delete(key string) error
List(agentID string) []SessionInfo
ListPaged(opts SessionListOpts) SessionListResult
ListPagedRich(opts SessionListOpts) SessionListRichResult
Save(key string) error
LastUsedChannel(agentID string) (channel, chatID string)
}
@@ -39,7 +39,7 @@ export function SessionSwitcher({
{sessions.map((session) => {
const parsed = parseSessionKey(session.key);
const isActive = session.key === activeKey;
const label = session.label || parsed.scope || session.key;
const label = session.metadata?.chat_title || session.metadata?.display_name || session.label || parsed.scope || session.key;
return (
<button
@@ -74,7 +74,7 @@ export function useSessions(opts: UseSessionsOptions = {}) {
);
const patchSession = useCallback(
async (key: string, updates: { label?: string; model?: string }) => {
async (key: string, updates: { label?: string; model?: string; metadata?: Record<string, string> }) => {
if (!ws.isConnected) return;
await ws.call(Methods.SESSIONS_PATCH, { key, ...updates });
await invalidate();
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useLayoutEffect } from "react";
import { ArrowLeft, Trash2, RotateCcw, Info, Eye } from "lucide-react";
import { ArrowLeft, Trash2, RotateCcw, Info, Eye, Pencil, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { MessageBubble } from "@/components/chat/message-bubble";
@@ -33,6 +33,7 @@ interface SessionDetailPageProps {
onPreview: (key: string) => Promise<SessionPreview | null>;
onDelete: (key: string) => Promise<void>;
onReset: (key: string) => Promise<void>;
onPatch?: (key: string, updates: { label?: string }) => Promise<void>;
}
export function SessionDetailPage({
@@ -41,12 +42,15 @@ export function SessionDetailPage({
onPreview,
onDelete,
onReset,
onPatch,
}: SessionDetailPageProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [summary, setSummary] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const parsed = parseSessionKey(session.key);
@@ -99,11 +103,51 @@ export function SessionDetailPage({
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h3 className="font-medium">
{session.metadata?.chat_title || session.metadata?.display_name || session.label || parsed.scope}
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{parsed.agentId}</Badge>
{editingTitle ? (
<div className="flex items-center gap-1">
<input
autoFocus
className="h-7 rounded border bg-background px-2 text-sm font-medium outline-none focus:ring-1 focus:ring-ring"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
onPatch?.(session.key, { label: titleDraft });
setEditingTitle(false);
} else if (e.key === "Escape") {
setEditingTitle(false);
}
}}
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
onPatch?.(session.key, { label: titleDraft });
setEditingTitle(false);
}}
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingTitle(false)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<h3
className="group flex cursor-pointer items-center gap-1.5 font-medium hover:text-primary"
onClick={() => {
setTitleDraft(session.label || session.metadata?.chat_title || session.metadata?.display_name || "");
setEditingTitle(true);
}}
>
{session.metadata?.chat_title || session.metadata?.display_name || session.label || parsed.scope}
<Pencil className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50" />
</h3>
)}
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{session.agentName || parsed.agentId}</Badge>
{session.channel && session.channel !== "ws" && (
<Badge variant="secondary" className="gap-1">
<Eye className="h-3 w-3" />
+3 -2
View File
@@ -21,7 +21,7 @@ export function SessionsPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const { sessions, total, loading, preview, deleteSession, resetSession } = useSessions({
const { sessions, total, loading, preview, deleteSession, resetSession, patchSession } = useSessions({
limit: pageSize,
offset: (page - 1) * pageSize,
});
@@ -44,6 +44,7 @@ export function SessionsPage() {
navigate("/sessions");
}}
onReset={resetSession}
onPatch={patchSession}
/>
);
}
@@ -146,7 +147,7 @@ function SessionRow({
</div>
</td>
<td className="px-4 py-3">
<Badge variant="outline">{parsed.agentId}</Badge>
<Badge variant="outline">{session.agentName || parsed.agentId}</Badge>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{session.channel || "ws"}
+1
View File
@@ -13,6 +13,7 @@ export interface SessionInfo {
outputTokens?: number;
userID?: string;
metadata?: Record<string, string>;
agentName?: string;
}
export interface SessionPreview {