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'
|
} from '@/components/ui/dialog'
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { renameSession, setSessionIcon } from '@/hermes'
|
import { renameSession } from '@/hermes'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { exportSession } from '@/lib/session-export'
|
import { exportSession } from '@/lib/session-export'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
import { setSessions } from '@/store/session'
|
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 {
|
interface SessionActions {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
title: string
|
title: string
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
profile?: string
|
profile?: string
|
||||||
icon?: null | string
|
|
||||||
onPin?: () => void
|
onPin?: () => void
|
||||||
onArchive?: () => void
|
onArchive?: () => void
|
||||||
onDelete?: () => void
|
onDelete?: () => void
|
||||||
@@ -52,18 +42,8 @@ interface ItemSpec {
|
|||||||
variant?: 'destructive'
|
variant?: 'destructive'
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSessionActions({
|
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
|
||||||
sessionId,
|
|
||||||
title,
|
|
||||||
pinned = false,
|
|
||||||
profile,
|
|
||||||
icon,
|
|
||||||
onPin,
|
|
||||||
onArchive,
|
|
||||||
onDelete
|
|
||||||
}: SessionActions) {
|
|
||||||
const [renameOpen, setRenameOpen] = useState(false)
|
const [renameOpen, setRenameOpen] = useState(false)
|
||||||
const [iconOpen, setIconOpen] = useState(false)
|
|
||||||
|
|
||||||
const items: ItemSpec[] = [
|
const items: ItemSpec[] = [
|
||||||
{
|
{
|
||||||
@@ -103,15 +83,6 @@ function useSessionActions({
|
|||||||
setRenameOpen(true)
|
setRenameOpen(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
disabled: !sessionId,
|
|
||||||
icon: 'symbol-color',
|
|
||||||
label: 'Icon',
|
|
||||||
onSelect: () => {
|
|
||||||
triggerHaptic('selection')
|
|
||||||
setIconOpen(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
disabled: !onArchive,
|
disabled: !onArchive,
|
||||||
icon: 'archive',
|
icon: 'archive',
|
||||||
@@ -143,23 +114,13 @@ function useSessionActions({
|
|||||||
))
|
))
|
||||||
|
|
||||||
const renameDialog = (
|
const renameDialog = (
|
||||||
<>
|
<RenameSessionDialog
|
||||||
<RenameSessionDialog
|
currentTitle={title}
|
||||||
currentTitle={title}
|
onOpenChange={setRenameOpen}
|
||||||
onOpenChange={setRenameOpen}
|
open={renameOpen}
|
||||||
open={renameOpen}
|
profile={profile}
|
||||||
profile={profile}
|
sessionId={sessionId}
|
||||||
sessionId={sessionId}
|
/>
|
||||||
/>
|
|
||||||
<IconPickerDialog
|
|
||||||
currentIcon={icon ?? null}
|
|
||||||
onOpenChange={setIconOpen}
|
|
||||||
open={iconOpen}
|
|
||||||
profile={profile}
|
|
||||||
sessionId={sessionId}
|
|
||||||
title={title}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return { renameDialog, renderItems }
|
return { renameDialog, renderItems }
|
||||||
@@ -294,67 +255,3 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
|||||||
</Dialog>
|
</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 (
|
return (
|
||||||
<SessionContextMenu
|
<SessionContextMenu
|
||||||
icon={session.icon}
|
|
||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onPin={onPin}
|
onPin={onPin}
|
||||||
@@ -159,11 +158,6 @@ export function SidebarSessionRow({
|
|||||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||||
</span>
|
</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">
|
<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}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
@@ -175,7 +169,6 @@ export function SidebarSessionRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<SessionActionsMenu
|
<SessionActionsMenu
|
||||||
icon={session.icon}
|
|
||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onPin={onPin}
|
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> {
|
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
|
||||||
return window.hermesDesktop.api<ModelInfoResponse>({
|
return window.hermesDesktop.api<ModelInfoResponse>({
|
||||||
...profileScoped(),
|
...profileScoped(),
|
||||||
|
|||||||
@@ -291,9 +291,6 @@ export interface SessionInfo {
|
|||||||
profile?: string
|
profile?: string
|
||||||
/** True when {@link profile} is the default profile. */
|
/** True when {@link profile} is the default profile. */
|
||||||
is_default_profile?: boolean
|
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 {
|
export interface SessionMessage {
|
||||||
|
|||||||
@@ -1667,7 +1667,10 @@ async def get_profiles_sessions(
|
|||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
continue
|
continue
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
errors.append({"profile": name, "error": str(exc)})
|
errors.append({"profile": name, "error": str(exc)})
|
||||||
continue
|
continue
|
||||||
@@ -4840,9 +4843,6 @@ async def delete_session_endpoint(session_id: str):
|
|||||||
class SessionRename(BaseModel):
|
class SessionRename(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
archived: Optional[bool] = 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
|
# Mutate a session belonging to another profile (opens its state.db). Omit
|
||||||
# for the current/default profile.
|
# for the current/default profile.
|
||||||
profile: Optional[str] = None
|
profile: Optional[str] = None
|
||||||
@@ -4850,21 +4850,21 @@ class SessionRename(BaseModel):
|
|||||||
|
|
||||||
@app.patch("/api/sessions/{session_id}")
|
@app.patch("/api/sessions/{session_id}")
|
||||||
async def rename_session_endpoint(session_id: str, body: SessionRename):
|
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
|
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
|
||||||
restores the session; ``icon`` sets a per-session glyph ("" clears it). Any
|
restores the session. Either field may be omitted. ``profile`` targets
|
||||||
field may be omitted. ``profile`` targets another profile's session.
|
another profile's session.
|
||||||
"""
|
"""
|
||||||
db = _open_session_db_for_profile(body.profile)
|
db = _open_session_db_for_profile(body.profile)
|
||||||
try:
|
try:
|
||||||
sid = db.resolve_session_id(session_id)
|
sid = db.resolve_session_id(session_id)
|
||||||
if not sid:
|
if not sid:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
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(
|
raise HTTPException(
|
||||||
status_code=400,
|
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:
|
if body.title is not None:
|
||||||
try:
|
try:
|
||||||
@@ -4874,13 +4874,9 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
|
|||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
if body.archived is not None:
|
if body.archived is not None:
|
||||||
db.set_session_archived(sid, body.archived)
|
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 ""}
|
result = {"ok": True, "title": db.get_session_title(sid) or ""}
|
||||||
if body.archived is not None:
|
if body.archived is not None:
|
||||||
result["archived"] = bool(body.archived)
|
result["archived"] = bool(body.archived)
|
||||||
if body.icon is not None:
|
|
||||||
result["icon"] = (body.icon or "").strip()[:16] or None
|
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
+22
-20
@@ -265,7 +265,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
handoff_error TEXT,
|
handoff_error TEXT,
|
||||||
rewind_count INTEGER NOT NULL DEFAULT 0,
|
rewind_count INTEGER NOT NULL DEFAULT 0,
|
||||||
archived INTEGER NOT NULL DEFAULT 0,
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
icon TEXT,
|
|
||||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -397,15 +396,35 @@ class SessionDB:
|
|||||||
# Attempt a PASSIVE WAL checkpoint every N successful writes.
|
# Attempt a PASSIVE WAL checkpoint every N successful writes.
|
||||||
_CHECKPOINT_EVERY_N_WRITES = 50
|
_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 = 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._lock = threading.Lock()
|
||||||
self._write_count = 0
|
self._write_count = 0
|
||||||
self._fts_enabled = False
|
self._fts_enabled = False
|
||||||
self._fts_unavailable_warned = False
|
self._fts_unavailable_warned = False
|
||||||
try:
|
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(
|
self._conn = sqlite3.connect(
|
||||||
str(self.db_path),
|
str(self.db_path),
|
||||||
check_same_thread=False,
|
check_same_thread=False,
|
||||||
@@ -1445,23 +1464,6 @@ class SessionDB:
|
|||||||
rowcount = self._execute_write(_do)
|
rowcount = self._execute_write(_do)
|
||||||
return rowcount > 0
|
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]]:
|
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Look up a session by exact title. Returns session dict or None."""
|
"""Look up a session by exact title. Returns session dict or None."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -381,36 +381,6 @@ class TestWebServerEndpoints:
|
|||||||
resp = self.client.patch("/api/sessions/no-fields", json={})
|
resp = self.client.patch("/api/sessions/no-fields", json={})
|
||||||
assert resp.status_code == 400
|
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):
|
def test_profiles_sessions_tags_default_profile(self):
|
||||||
"""The cross-profile aggregator returns the default profile's rows
|
"""The cross-profile aggregator returns the default profile's rows
|
||||||
tagged profile="default" (single-profile parity with /api/sessions)."""
|
tagged profile="default" (single-profile parity with /api/sessions)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user