refactor(desktop): drop per-session icons, read-only cross-profile reads

The per-session icon picker added more noise than value — rip it out end
to end (sessions.icon column, set_session_icon, the PATCH field, the
picker UI, and the SessionInfo.icon type).

The cross-profile session aggregator now opens each profile's state.db
read-only (mode=ro, no schema init), so listing other profiles on every
sidebar refresh never DDLs or takes a write lock on their live DBs. The
single-profile hot path stays on par with /api/sessions.
This commit is contained in:
Brooklyn Nicholson
2026-06-04 18:24:35 -05:00
parent 48d8d80771
commit cf9dc366dd
7 changed files with 40 additions and 199 deletions

View File

@ -15,27 +15,17 @@ import {
} from '@/components/ui/dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession, setSessionIcon } from '@/hermes'
import { renameSession } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
// Curated glyphs for the per-session icon picker. Kept short so sessions from
// different profiles stay visually distinguishable at a glance.
const SESSION_ICON_CHOICES = [
'🦊', '🐙', '🦉', '🐝', '🐢', '🦄', '🚀', '⚡',
'🔥', '🌙', '⭐', '🎯', '🧪', '🔭', '🛠️', '🔒',
'💼', '📦', '🎨', '🧠'
] as const
interface SessionActions {
sessionId: string
title: string
pinned?: boolean
profile?: string
icon?: null | string
onPin?: () => void
onArchive?: () => void
onDelete?: () => void
@ -52,18 +42,8 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({
sessionId,
title,
pinned = false,
profile,
icon,
onPin,
onArchive,
onDelete
}: SessionActions) {
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
const [renameOpen, setRenameOpen] = useState(false)
const [iconOpen, setIconOpen] = useState(false)
const items: ItemSpec[] = [
{
@ -103,15 +83,6 @@ function useSessionActions({
setRenameOpen(true)
}
},
{
disabled: !sessionId,
icon: 'symbol-color',
label: 'Icon',
onSelect: () => {
triggerHaptic('selection')
setIconOpen(true)
}
},
{
disabled: !onArchive,
icon: 'archive',
@ -143,23 +114,13 @@ function useSessionActions({
))
const renameDialog = (
<>
<RenameSessionDialog
currentTitle={title}
onOpenChange={setRenameOpen}
open={renameOpen}
profile={profile}
sessionId={sessionId}
/>
<IconPickerDialog
currentIcon={icon ?? null}
onOpenChange={setIconOpen}
open={iconOpen}
profile={profile}
sessionId={sessionId}
title={title}
/>
</>
<RenameSessionDialog
currentTitle={title}
onOpenChange={setRenameOpen}
open={renameOpen}
profile={profile}
sessionId={sessionId}
/>
)
return { renameDialog, renderItems }
@ -294,67 +255,3 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
</Dialog>
)
}
interface IconPickerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
sessionId: string
title: string
currentIcon: null | string
profile?: string
}
function IconPickerDialog({ open, onOpenChange, sessionId, title, currentIcon, profile }: IconPickerDialogProps) {
const [submitting, setSubmitting] = useState(false)
const apply = async (icon: string) => {
if (!sessionId || submitting) {
return
}
setSubmitting(true)
try {
const result = await setSessionIcon(sessionId, icon, profile)
const finalIcon = result.icon ?? null
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, icon: finalIcon } : s)))
onOpenChange(false)
} catch (err) {
notifyError(err, 'Could not set icon')
} finally {
setSubmitting(false)
}
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Session icon</DialogTitle>
<DialogDescription>Pick a glyph for {title || 'this session'} so it stands out in the list.</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-8 gap-1.5">
{SESSION_ICON_CHOICES.map(glyph => (
<button
className={cn(
'grid aspect-square place-items-center rounded-md border border-transparent text-lg transition-colors hover:bg-(--ui-control-hover-background)',
currentIcon === glyph && 'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background)'
)}
disabled={submitting}
key={glyph}
onClick={() => void apply(glyph)}
type="button"
>
{glyph}
</button>
))}
</div>
<DialogFooter>
<Button disabled={submitting || !currentIcon} onClick={() => void apply('')} type="button" variant="ghost">
Clear icon
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -70,7 +70,6 @@ export function SidebarSessionRow({
return (
<SessionContextMenu
icon={session.icon}
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}
@ -159,11 +158,6 @@ export function SidebarSessionRow({
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
{session.icon ? (
<span aria-hidden="true" className="shrink-0 text-xs leading-none">
{session.icon}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
@ -175,7 +169,6 @@ export function SidebarSessionRow({
</span>
)}
<SessionActionsMenu
icon={session.icon}
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}

View File

@ -209,20 +209,6 @@ export function renameSession(
})
}
// Set ("" clears) a per-session icon glyph. `profile` targets another profile's
// session (the backend opens its state.db). Omit for the current profile.
export function setSessionIcon(
id: string,
icon: string,
profile?: string | null
): Promise<{ ok: boolean; icon: string | null }> {
return window.hermesDesktop.api<{ ok: boolean; icon: string | null }>({
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { icon, ...(profile ? { profile } : {}) }
})
}
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
return window.hermesDesktop.api<ModelInfoResponse>({
...profileScoped(),

View File

@ -291,9 +291,6 @@ export interface SessionInfo {
profile?: string
/** True when {@link profile} is the default profile. */
is_default_profile?: boolean
/** Optional per-session glyph the user picked so sessions from different
* profiles are visually distinguishable in the unified list. */
icon?: null | string
}
export interface SessionMessage {

View File

@ -1667,7 +1667,10 @@ async def get_profiles_sessions(
if not db_path.exists():
continue
try:
db = SessionDB(db_path=db_path)
# Read-only: this loop runs on every sidebar refresh, so it must
# never DDL/write-lock another profile's live DB (see SessionDB
# read_only docstring).
db = SessionDB(db_path=db_path, read_only=True)
except Exception as exc:
errors.append({"profile": name, "error": str(exc)})
continue
@ -4840,9 +4843,6 @@ async def delete_session_endpoint(session_id: str):
class SessionRename(BaseModel):
title: Optional[str] = None
archived: Optional[bool] = None
# Tri-state via the client contract: omit to leave unchanged, "" to clear,
# a short glyph to set. (None == omitted here.)
icon: Optional[str] = None
# Mutate a session belonging to another profile (opens its state.db). Omit
# for the current/default profile.
profile: Optional[str] = None
@ -4850,21 +4850,21 @@ class SessionRename(BaseModel):
@app.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Update a session: rename (or clear its title), archive, and/or set icon.
"""Update a session: rename (or clear its title) and/or archive it.
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
restores the session; ``icon`` sets a per-session glyph ("" clears it). Any
field may be omitted. ``profile`` targets another profile's session.
restores the session. Either field may be omitted. ``profile`` targets
another profile's session.
"""
db = _open_session_db_for_profile(body.profile)
try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
if body.title is None and body.archived is None and body.icon is None:
if body.title is None and body.archived is None:
raise HTTPException(
status_code=400,
detail="Nothing to update; provide 'title', 'archived', and/or 'icon'.",
detail="Nothing to update; provide 'title' and/or 'archived'.",
)
if body.title is not None:
try:
@ -4874,13 +4874,9 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
raise HTTPException(status_code=400, detail=str(e))
if body.archived is not None:
db.set_session_archived(sid, body.archived)
if body.icon is not None:
db.set_session_icon(sid, body.icon)
result = {"ok": True, "title": db.get_session_title(sid) or ""}
if body.archived is not None:
result["archived"] = bool(body.archived)
if body.icon is not None:
result["icon"] = (body.icon or "").strip()[:16] or None
return result
finally:
db.close()

View File

@ -265,7 +265,6 @@ CREATE TABLE IF NOT EXISTS sessions (
handoff_error TEXT,
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
icon TEXT,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
@ -397,15 +396,35 @@ class SessionDB:
# Attempt a PASSIVE WAL checkpoint every N successful writes.
_CHECKPOINT_EVERY_N_WRITES = 50
def __init__(self, db_path: Path = None):
def __init__(self, db_path: Path = None, read_only: bool = False):
self.db_path = db_path or DEFAULT_DB_PATH
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.read_only = read_only
self._lock = threading.Lock()
self._write_count = 0
self._fts_enabled = False
self._fts_unavailable_warned = False
try:
if read_only:
# Read-only attach for cross-profile aggregation: SELECT-only,
# so we skip schema init entirely (no DDL, no FTS probe, no
# column reconcile). Crucially this takes NO write lock, so
# polling another profile's live DB on every sidebar refresh
# never contends with that profile's running backend. The DB
# must already exist + be initialised (callers guard on
# db_path.exists()); a SELECT against an empty file raises and
# the caller degrades per-profile.
self._conn = sqlite3.connect(
f"file:{self.db_path}?mode=ro",
uri=True,
check_same_thread=False,
timeout=1.0,
isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
return
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(
str(self.db_path),
check_same_thread=False,
@ -1445,23 +1464,6 @@ class SessionDB:
rowcount = self._execute_write(_do)
return rowcount > 0
def set_session_icon(self, session_id: str, icon: Optional[str]) -> bool:
"""Set or clear a session's user-chosen icon glyph.
``icon`` is a short display string (an emoji or a couple of chars);
passing None/"" clears it. Returns True when a row was updated.
"""
cleaned = (icon or "").strip()[:16] or None
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET icon = ? WHERE id = ?",
(cleaned, session_id),
)
return cursor.rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
with self._lock:

View File

@ -381,36 +381,6 @@ class TestWebServerEndpoints:
resp = self.client.patch("/api/sessions/no-fields", json={})
assert resp.status_code == 400
def test_set_and_clear_session_icon_via_patch(self):
"""PATCH icon sets a per-session glyph; "" clears it back to None."""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="icon-me", source="cli")
finally:
db.close()
resp = self.client.patch("/api/sessions/icon-me", json={"icon": "🦊"})
assert resp.status_code == 200
assert resp.json()["icon"] == "🦊"
db = SessionDB()
try:
assert db.get_session("icon-me")["icon"] == "🦊"
finally:
db.close()
resp = self.client.patch("/api/sessions/icon-me", json={"icon": ""})
assert resp.status_code == 200
assert resp.json()["icon"] is None
db = SessionDB()
try:
assert db.get_session("icon-me")["icon"] is None
finally:
db.close()
def test_profiles_sessions_tags_default_profile(self):
"""The cross-profile aggregator returns the default profile's rows
tagged profile="default" (single-profile parity with /api/sessions)."""