diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 8fbbfb912..d3dcecb36 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -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 = ( - <> - - - > + ) return { renameDialog, renderItems } @@ -294,67 +255,3 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof ) } - -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 ( - - - - Session icon - Pick a glyph for โ{title || 'this session'}โ so it stands out in the list. - - - {SESSION_ICON_CHOICES.map(glyph => ( - void apply(glyph)} - type="button" - > - {glyph} - - ))} - - - void apply('')} type="button" variant="ghost"> - Clear icon - - - - - ) -} diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index b951c012e..8542619f4 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -70,7 +70,6 @@ export function SidebarSessionRow({ return ( )} - {session.icon ? ( - - {session.icon} - - ) : null} {title} @@ -175,7 +169,6 @@ export function SidebarSessionRow({ )} { - return window.hermesDesktop.api<{ ok: boolean; icon: string | null }>({ - path: `/api/sessions/${encodeURIComponent(id)}`, - method: 'PATCH', - body: { icon, ...(profile ? { profile } : {}) } - }) -} - export function getGlobalModelInfo(): Promise { return window.hermesDesktop.api({ ...profileScoped(), diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 6bc4e44f8..bec52cb1c 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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 { diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d0eb8a3ce..234818451 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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() diff --git a/hermes_state.py b/hermes_state.py index c8fd3a827..6a345a4b5 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 6a1d20db2..adc2fb8ff 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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)."""