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:
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user