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 {