fix: chat session reliability, cost, and hygiene (empty-response guard, tool truncation, delete, titles)
- Empty/refusal responses retried once, then surfaced as errors instead of silent blanks
- Chinese refusal boilerplate detected via denylist + non-ASCII heuristic
- Bulk-tool preference added to SOUL.md (list_lxcs over per-entity get_lxc_state)
- Tool results truncated to 4KB on persist; get_state_snapshot filters null-state entities
- Session delete (DELETE /sessions/{id} + confirm-on-second-click UI)
- Session titles auto-generated from assistant answer instead of raw user message
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -15,6 +16,15 @@ import (
|
||||
)
|
||||
|
||||
const maxIterations = 15
|
||||
const maxLLMRetries = 1
|
||||
|
||||
var refusalDenylist = []string{
|
||||
"我没有相关信息",
|
||||
"您可以尝试问我其它问题",
|
||||
"我无法",
|
||||
"抱歉,我无法",
|
||||
"关于这个问题,我没有",
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
client *mcpClient
|
||||
@@ -105,14 +115,6 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
return
|
||||
}
|
||||
|
||||
// Rebuild conversation context from persisted history so sessions are
|
||||
// multi-turn. The current user turn is saved by the HTTP handler before
|
||||
// this runs, so it is already included in the history for real sessions.
|
||||
// Prior tool_use/tool_result pairs are replayed as a tool-calling
|
||||
// assistant message followed by matching tool-role results, so the agent
|
||||
// starts each turn already knowing what it already checked instead of
|
||||
// re-querying the same tools from scratch. Ephemeral sessions (no store)
|
||||
// fall back to the single incoming message.
|
||||
system := a.system
|
||||
if snapshot := a.fleetSnapshot(); snapshot != "" {
|
||||
system += "\n\n" + snapshot
|
||||
@@ -147,30 +149,54 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
Tools: tools,
|
||||
}
|
||||
|
||||
// Stream the completion, emitting token deltas as they arrive. The
|
||||
// accumulator reassembles the full message (content + tool calls) for
|
||||
// the loop's control flow.
|
||||
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
|
||||
acc := openai.ChatCompletionAccumulator{}
|
||||
for stream.Next() {
|
||||
chunk := stream.Current()
|
||||
acc.AddChunk(chunk)
|
||||
if len(chunk.Choices) > 0 {
|
||||
if delta := chunk.Choices[0].Delta.Content; delta != "" {
|
||||
emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1})
|
||||
var msg openai.ChatCompletionMessage
|
||||
var acc openai.ChatCompletionAccumulator
|
||||
|
||||
for attempt := 0; attempt <= maxLLMRetries; attempt++ {
|
||||
acc = openai.ChatCompletionAccumulator{}
|
||||
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
|
||||
for stream.Next() {
|
||||
chunk := stream.Current()
|
||||
acc.AddChunk(chunk)
|
||||
if len(chunk.Choices) > 0 {
|
||||
if delta := chunk.Choices[0].Delta.Content; delta != "" {
|
||||
emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := stream.Err(); err != nil {
|
||||
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID})
|
||||
return
|
||||
}
|
||||
if len(acc.Choices) == 0 {
|
||||
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID})
|
||||
return
|
||||
}
|
||||
if err := stream.Err(); err != nil {
|
||||
if attempt < maxLLMRetries {
|
||||
slog.Warn("nomos: llm stream error, retrying", "error", err, "attempt", attempt+1, "session", sessionID)
|
||||
continue
|
||||
}
|
||||
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID})
|
||||
return
|
||||
}
|
||||
if len(acc.Choices) == 0 {
|
||||
if attempt < maxLLMRetries {
|
||||
slog.Warn("nomos: no choices in response, retrying", "attempt", attempt+1, "session", sessionID)
|
||||
continue
|
||||
}
|
||||
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID})
|
||||
return
|
||||
}
|
||||
|
||||
msg := acc.Choices[0].Message
|
||||
msg = acc.Choices[0].Message
|
||||
|
||||
if len(msg.ToolCalls) == 0 {
|
||||
if isRefusalOrEmpty(msg.Content) {
|
||||
if attempt < maxLLMRetries {
|
||||
slog.Warn("nomos: empty or refusal response, retrying",
|
||||
"session", sessionID, "iter", i+1, "attempt", attempt+1,
|
||||
"content_len", len(msg.Content))
|
||||
continue
|
||||
}
|
||||
emit(agentEvent{Type: "error", Data: "Nomos returned an empty or unusable response — please retry.", SessionID: sessionID})
|
||||
return
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if len(msg.ToolCalls) == 0 {
|
||||
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
||||
@@ -388,6 +414,33 @@ func (a *agent) fleetSnapshot() string {
|
||||
return summary
|
||||
}
|
||||
|
||||
// isRefusalOrEmpty returns true when the LLM response is blank or looks like a
|
||||
// canned non-English refusal to an English-language conversation. Flash-tier
|
||||
// models occasionally emit Chinese boilerplate deflection instead of a real
|
||||
// answer; this catches it before it reaches the UI.
|
||||
func isRefusalOrEmpty(text string) bool {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return true
|
||||
}
|
||||
ascii, nonASCII := 0, 0
|
||||
for _, r := range text {
|
||||
if r <= 127 {
|
||||
ascii++
|
||||
} else {
|
||||
nonASCII++
|
||||
}
|
||||
}
|
||||
if nonASCII > ascii {
|
||||
return true
|
||||
}
|
||||
for _, pattern := range refusalDenylist {
|
||||
if strings.Contains(text, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
|
||||
defs, err := a.client.listToolsFull()
|
||||
if err != nil {
|
||||
|
||||
@@ -186,6 +186,15 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
"tool_calls": toolCalls,
|
||||
})
|
||||
st.saveMessage(ctx, sessionID, "assistant", assistantMsg)
|
||||
|
||||
// Generate a meaningful title from the assistant's first answer
|
||||
// instead of reusing the raw user message for every session.
|
||||
if finalText != "" && sessionID != "ephemeral" {
|
||||
title := truncate(finalText, 80)
|
||||
if title != "" {
|
||||
st.updateSessionTitle(ctx, sessionID, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
|
||||
@@ -220,13 +229,26 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) {
|
||||
return
|
||||
}
|
||||
|
||||
messages, err := st.getMessages(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if err := st.deleteSession(r.Context(), id); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
|
||||
case http.MethodGet:
|
||||
messages, err := st.getMessages(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", 405)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
|
||||
}
|
||||
|
||||
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const maxToolResultSize = 4096
|
||||
|
||||
type store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
@@ -71,10 +73,45 @@ func (s *store) saveMessage(ctx context.Context, sessionID, role string, content
|
||||
}
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`,
|
||||
sessionID, role, content)
|
||||
sessionID, role, truncateToolResults(content))
|
||||
return err
|
||||
}
|
||||
|
||||
func truncateToolResults(content json.RawMessage) json.RawMessage {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(content, &m); err != nil {
|
||||
return content
|
||||
}
|
||||
toolCalls, ok := m["tool_calls"].([]any)
|
||||
if !ok || len(toolCalls) == 0 {
|
||||
return content
|
||||
}
|
||||
changed := false
|
||||
for i, raw := range toolCalls {
|
||||
tc, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if result, ok := tc["result"]; ok {
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
if len(resultJSON) > maxToolResultSize {
|
||||
tc["result"] = string(resultJSON[:maxToolResultSize]) + fmt.Sprintf("...truncated (%d bytes total)", len(resultJSON))
|
||||
toolCalls[i] = tc
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return content
|
||||
}
|
||||
m["tool_calls"] = toolCalls
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *store) touchSession(ctx context.Context, id string) {
|
||||
if s != nil {
|
||||
s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id)
|
||||
@@ -126,6 +163,26 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *store) deleteSession(ctx context.Context, id string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) updateSessionTitle(ctx context.Context, id, title string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `UPDATE agent_sessions SET title = $1 WHERE id = $2`, title, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos").
|
||||
// Returns uuid.Nil if the store is absent or the slug is unknown.
|
||||
func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {
|
||||
|
||||
@@ -698,6 +698,8 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
COALESCE(st.last_check_at::text, '') AS last_check
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.state IS NOT NULL
|
||||
OR st.health IS NOT NULL
|
||||
ORDER BY st.health, e.slug
|
||||
LIMIT 200
|
||||
`), nil
|
||||
|
||||
@@ -23,14 +23,30 @@ the actuator (a separate container with restricted SSH key) picks up.
|
||||
|
||||
## Key MCP tools
|
||||
|
||||
- `get_entity`, `list_entities` — resolve slugs to state
|
||||
- `list_lxcs` — all LXC containers with host, IP, health (use for fleet-wide questions)
|
||||
- `get_lxc_state` — per-container `pct status` (use only for a specific named container)
|
||||
- `get_state_snapshot` — fleet health, disk, drift at a glance
|
||||
- `get_health_summary` — fleet health counts
|
||||
- `query_metrics` — time-series metrics (prefer over per-entity `get_trend` for fleet-wide)
|
||||
- `list_entities` — resolve slugs to state (pass `type` filter when possible)
|
||||
- `get_entity` — single-entity detail
|
||||
- `get_blast_radius` — understand impact before requesting action
|
||||
- `get_health_summary` — fleet status at a glance
|
||||
- `get_signal_history` — open alerts
|
||||
- `get_trend` — metric trends for decisions
|
||||
- `get_trend` — metric trends for a specific entity (single-entity only)
|
||||
- `request_execution` — the ONLY mutation path
|
||||
- `get_agent_activity` — your own behavior log
|
||||
|
||||
### Tool selection rules
|
||||
|
||||
- **Fleet-wide questions** (e.g. "which hosts are saturated?", "what needs updating?"):
|
||||
prefer bulk tools: `list_lxcs`, `get_health_summary`, `get_state_snapshot`,
|
||||
`query_metrics`. Only fall back to per-entity tools (`get_lxc_state`, `tail_log`,
|
||||
`get_trend`) for a specific named entity the user asked about.
|
||||
- **One call > many calls**: each `get_lxc_state` is a live SSH round-trip.
|
||||
`list_lxcs` answers the same question in one call. Use it.
|
||||
- When a bulk tool's summary isn't enough for a specific entity, call the
|
||||
per-entity tool for that one entity — not for every entity in the fleet.
|
||||
|
||||
## Policy awareness
|
||||
|
||||
Before calling `request_execution`:
|
||||
|
||||
@@ -31,6 +31,11 @@ export async function fetchMessages(sessionId: string): Promise<Message[]> {
|
||||
return data.messages ?? []
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<boolean> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
return res.ok
|
||||
}
|
||||
|
||||
export interface ChatEvent {
|
||||
type: string
|
||||
data: any
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat } from '$lib/stores/chat'
|
||||
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat, deleteSession } from '$lib/stores/chat'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash2'
|
||||
|
||||
onMount(() => {
|
||||
loadSessions()
|
||||
})
|
||||
|
||||
// Pick up sessions created/renamed elsewhere (e.g. after a turn completes).
|
||||
$effect(() => {
|
||||
void $currentSession
|
||||
loadSessions()
|
||||
})
|
||||
|
||||
let confirmDelete = $state<string | null>(null)
|
||||
|
||||
function handleDelete(e: MouseEvent, id: string) {
|
||||
e.stopPropagation()
|
||||
if (confirmDelete === id) {
|
||||
deleteSession(id)
|
||||
confirmDelete = null
|
||||
} else {
|
||||
confirmDelete = id
|
||||
// Hide confirmation after 3s
|
||||
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(sessionId: string) {
|
||||
confirmDelete = null
|
||||
loadSessionMessages(sessionId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
|
||||
@@ -27,10 +46,23 @@
|
||||
{#each $sessions as session (session.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
|
||||
onclick={() => loadSessionMessages(session.id)}
|
||||
class="group flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
|
||||
onclick={() => handleClick(session.id)}
|
||||
>
|
||||
<span class="w-full truncate font-medium">{session.title || 'Untitled'}</span>
|
||||
<span class="flex w-full items-center justify-between gap-1">
|
||||
<span class="min-w-0 truncate font-medium">{session.title || 'Untitled'}</span>
|
||||
<span
|
||||
class="shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/20 hover:text-destructive"
|
||||
onclick={(e) => handleDelete(e, session.id)}
|
||||
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
||||
>
|
||||
{#if confirmDelete === session.id}
|
||||
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
|
||||
{:else}
|
||||
<Trash2Icon class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable, get } from 'svelte/store'
|
||||
import { streamChat, fetchSessions, fetchMessages } from '$lib/api'
|
||||
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
|
||||
import type { ChatEvent, Session, Message } from '$lib/api'
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -177,3 +177,12 @@ export function cancelStream() {
|
||||
streaming.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string) {
|
||||
const ok = await apiDeleteSession(sessionId)
|
||||
if (!ok) return
|
||||
if (get(currentSession) === sessionId) {
|
||||
newChat()
|
||||
}
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
@@ -1,29 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { sessions, loadSessions, loadSessionMessages, messages, currentSession } from '$lib/stores/chat'
|
||||
import { sessions, loadSessions, loadSessionMessages, deleteSession, currentSession } from '$lib/stores/chat'
|
||||
import { onMount } from 'svelte'
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash2'
|
||||
|
||||
onMount(() => {
|
||||
loadSessions()
|
||||
})
|
||||
|
||||
let confirmDelete = $state<string | null>(null)
|
||||
|
||||
function handleDelete(e: MouseEvent, id: string) {
|
||||
e.stopPropagation()
|
||||
if (confirmDelete === id) {
|
||||
deleteSession(id)
|
||||
confirmDelete = null
|
||||
} else {
|
||||
confirmDelete = id
|
||||
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sessions-page">
|
||||
<h2>Sessions</h2>
|
||||
<div class="session-list">
|
||||
{#each $sessions as session (session.id)}
|
||||
<button
|
||||
class="session-card"
|
||||
<div
|
||||
class="session-card group"
|
||||
class:active={$currentSession === session.id}
|
||||
onclick={() => {
|
||||
loadSessionMessages(session.id)
|
||||
location.hash = '#/chat'
|
||||
}}
|
||||
>
|
||||
<div class="session-title">{session.title || 'Untitled'}</div>
|
||||
<div class="session-meta">
|
||||
{new Date(session.last_active_at).toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="session-content"
|
||||
onclick={() => {
|
||||
loadSessionMessages(session.id)
|
||||
location.hash = '#/chat'
|
||||
}}
|
||||
>
|
||||
<div class="session-title">{session.title || 'Untitled'}</div>
|
||||
<div class="session-meta">
|
||||
{new Date(session.last_active_at).toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="session-delete"
|
||||
onclick={(e) => handleDelete(e, session.id)}
|
||||
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
||||
aria-label="Delete session"
|
||||
>
|
||||
{#if confirmDelete === session.id}
|
||||
<span class="confirm-text">Delete?</span>
|
||||
{:else}
|
||||
<Trash2Icon class="icon" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">No sessions yet. Start chatting with Nomos.</div>
|
||||
{/each}
|
||||
@@ -54,15 +84,12 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
@@ -75,6 +102,58 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.session-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.session-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 4px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.group:hover .session-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-delete:hover {
|
||||
background: var(--destructive-bg-subtle, rgba(239, 68, 68, 0.1));
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user