Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lat.md/sidebar-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ The menu is styled light-based and stroke-free (`.sidebar-session-menu`): no 1px

Each action calls an existing desktop API with an optimistic local update and rollback on failure: Rename → `updateSessionTitle` (inline `.sidebar-recent-session-rename` input), Move → [[src/main/session-context-folder-store.ts#setSessionContextFolder]] then a `hermes-session-context-folder-changed` event so other surfaces re-group, Delete → a confirmation dialog (portal overlay) then [[src/main/sessions.ts#deleteSessionRows|deleteSession]]. Deleting the open chat calls `onSessionDeleted`, which [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] uses to drop to a fresh New Chat.

### Rename persistence

Session renames must survive `syncSessionCache`, so the durable `state.db` write happens before the JSON cache is updated.

Title policy lives in [[src/shared/session-title.ts]] (`normalizeSessionTitle`, `MAX_SESSION_TITLE_LENGTH`) so the renderer optimistic path and [[src/main/session-cache.ts#updateSessionTitle]] cannot diverge. Main writes `state.db` first (Hermes `idx_sessions_title_unique`), then mirrors into `sessions.json`; failures throw. Both the sidebar and Sessions modal call [[src/renderer/src/screens/Sessions/confirmSessionRename.ts#confirmSessionRename]] for optimistic update, toast/rollback, and keep-editor-on-failure.

Pinned rows are a desktop-only affordance: their ids live in `localStorage` (`hermes.sidebar.pinnedSessions`), and pinned sessions are pulled out of the normal grouping into a collapsible **Pinned** section at the top of the list.

## Full-list modal
Expand Down
99 changes: 74 additions & 25 deletions src/main/session-cache.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import {
activeStateDbPath,
profileHome,
getActiveProfileNameSync,
safeWriteFile,
} from "./utils";
import { profileHome, getActiveProfileNameSync, safeWriteFile } from "./utils";
import Database from "better-sqlite3";
import { t } from "../shared/i18n";
import {
isSessionTitleUniqueViolation,
MAX_SESSION_TITLE_LENGTH,
normalizeSessionTitle,
validateNormalizedSessionTitle,
} from "../shared/session-title";
import { getAppLocale } from "./locale";
import { getDbConnection } from "./db";
import { getSessionContextFolders } from "./session-context-folder-store";

// Re-export for callers/docs that historically imported the cap from here.
export { MAX_SESSION_TITLE_LENGTH } from "../shared/session-title";

/**
* The session cache lives alongside its own profile's data so profiles
* don't share a single cache file. The default profile keeps
Expand Down Expand Up @@ -258,31 +262,76 @@ export function listCachedSessions(limit = 50, offset = 0): CachedSession[] {
return cache.sessions.slice(offset, offset + limit);
}

// Update title for a specific session
/**
* Persist a user-chosen session title to state.db, then mirror it into the
* desktop sessions.json cache.
*
* Order matters: the durable DB write must succeed before the cache is
* updated. The previous cache-first + swallow-errors approach left the UI
* looking renamed while the next syncSessionCache restored the old DB title
* (Hermes enforces UNIQUE non-NULL titles via idx_sessions_title_unique).
*/
export function updateSessionTitle(sessionId: string, title: string): void {
const locale = getAppLocale();
const normalized = normalizeSessionTitle(title);
const validation = validateNormalizedSessionTitle(normalized);
switch (validation) {
case "empty":
throw new Error(t("sessions.renameInvalid", locale));
case "too_long":
throw new Error(
t("sessions.renameTooLong", locale, {
max: String(MAX_SESSION_TITLE_LENGTH),
}),
);
case null:
break;
default: {
const _exhaustive: never = validation;
throw new Error(String(_exhaustive));
}
}

const db = getDbConnection(false);
if (!db) {
throw new Error(t("sessions.renameUnavailable", locale));
}

// Match Hermes SessionDB.set_session_title: reject conflicts before write so
// we never partially update the JSON cache on a UNIQUE constraint failure.
const conflict = db
.prepare("SELECT id FROM sessions WHERE title = ? AND id != ?")
.get(normalized, sessionId) as { id: string } | undefined;
if (conflict) {
throw new Error(
t("sessions.renameDuplicate", locale, { title: normalized }),
);
}

let changes = 0;
try {
changes = db
.prepare("UPDATE sessions SET title = ? WHERE id = ?")
.run(normalized, sessionId).changes;
} catch (err) {
if (isSessionTitleUniqueViolation(err)) {
throw new Error(
t("sessions.renameDuplicate", locale, { title: normalized }),
);
}
throw err instanceof Error ? err : new Error(String(err));
}

if (changes === 0) {
throw new Error(t("sessions.renameNotFound", locale));
}

const cache = readCache();
const idx = cache.sessions.findIndex((s) => s.id === sessionId);
if (idx >= 0) {
cache.sessions[idx].title = title;
cache.sessions[idx].title = normalized;
writeCache(cache);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cache Mirror Failure Is Hidden

The database has already committed when writeCache runs, but that function suppresses every write error. If disk access fails, this call still reports success while sessions.json keeps the old title; later incremental syncs do not repair established sessions because they select by unchanged started_at and refresh only message counts in the stale-entry pass.

}
// Also persist in state.db so the rename survives cache rebuilds
try {
const dbPath = activeStateDbPath();
if (existsSync(dbPath)) {
const db = new Database(dbPath);
try {
db.prepare("UPDATE sessions SET title = ? WHERE id = ?").run(
title,
sessionId,
);
} finally {
db.close();
}
}
} catch {
// ignore DB errors — cache update above is the fast path
}
}

// Remove a session entry from the local cache. Called after the underlying
Expand Down
44 changes: 23 additions & 21 deletions src/renderer/src/screens/Layout/SidebarRecentSessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Pin,
X,
} from "../../assets/icons";
import { confirmSessionRename } from "../Sessions/confirmSessionRename";
import SidebarSessionMenu, {
type SidebarMenuProject,
type SidebarMenuTarget,
Expand Down Expand Up @@ -509,28 +510,29 @@ const SidebarRecentSessions = memo(function SidebarRecentSessions({

const confirmRename = useCallback(
async (id: string, value: string): Promise<void> => {
const trimmed = value.trim();
const current = sessionsRef.current.find((s) => s.id === id);
if (!trimmed || trimmed === (current?.title ?? "")) {
cancelRename();
return;
}
const previous = current?.title ?? "";
// Optimistic local update; roll back if the write fails.
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title: trimmed } : s)),
);
if (editingIdRef.current === id) cancelRename();
try {
await window.hermesAPI.updateSessionTitle(id, trimmed);
} catch (err) {
console.error("Failed to rename session", id, err);
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title: previous } : s)),
);
}
const previous =
sessionsRef.current.find((s) => s.id === id)?.title ?? "";
await confirmSessionRename({
sessionId: id,
value,
currentTitle: previous,
isStillEditing: () => editingIdRef.current === id,
applyOptimistic: (title) =>
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title } : s)),
),
rollback: () =>
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, title: previous } : s)),
),
clearEditing: cancelRename,
inputRef: renameInputRef,
fallbackErrorMessage: t("sessions.renameFailed"),
persist: (sessionId, title) =>
window.hermesAPI.updateSessionTitle(sessionId, title),
});
},
[cancelRename],
[cancelRename, t],
);

const handleMoveToProject = useCallback(
Expand Down
83 changes: 37 additions & 46 deletions src/renderer/src/screens/Sessions/Sessions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState, useRef, useCallback, useMemo, memo } from "react";
import { Plus, Search, X, ChatBubble, Trash, Pencil } from "../../assets/icons";
import { useI18n } from "../../components/useI18n";
import { confirmSessionRename } from "./confirmSessionRename";

interface CachedSession {
id: string;
Expand Down Expand Up @@ -393,54 +394,44 @@ function Sessions({

const confirmRename = useCallback(
async (sessionId: string, newTitle: string): Promise<void> => {
const trimmed = newTitle.trim();
if (!trimmed) {
cancelRename();
return;
}
// Capture old titles so we can roll back on failure.
let oldSessionTitle = "";
let oldSearchResultTitle = "";
// Optimistic update
setSessions((prev) => {
oldSessionTitle = prev.find((s) => s.id === sessionId)?.title ?? "";
return prev.map((s) =>
s.id === sessionId ? { ...s, title: trimmed } : s,
);
const oldSessionTitle =
sessions.find((s) => s.id === sessionId)?.title ?? "";
const oldSearchResultTitle =
searchResults.find((r) => r.sessionId === sessionId)?.title ?? "";
Comment on lines +397 to +400

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Older Rollback Overwrites Newer Rename

Two submissions for the same session can overlap while persistence is pending. If rename B succeeds before rename A fails, A's snapshot rollback runs last and restores the original title over B's optimistic title, leaving the modal inconsistent with the title persisted by B until another refresh.

await confirmSessionRename({
sessionId,
value: newTitle,
currentTitle: oldSessionTitle,
isStillEditing: () => editingSessionIdRef.current === sessionId,
applyOptimistic: (title) => {
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, title } : s)),
);
setSearchResults((prev) =>
prev.map((r) => (r.sessionId === sessionId ? { ...r, title } : r)),
);
},
rollback: () => {
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId ? { ...s, title: oldSessionTitle } : s,
),
);
setSearchResults((prev) =>
prev.map((r) =>
r.sessionId === sessionId
? { ...r, title: oldSearchResultTitle }
: r,
),
);
},
clearEditing: cancelRename,
inputRef: renameInputRef,
fallbackErrorMessage: t("sessions.renameFailed"),
persist: (id, title) => window.hermesAPI.updateSessionTitle(id, title),
});
setSearchResults((prev) => {
oldSearchResultTitle =
prev.find((r) => r.sessionId === sessionId)?.title ?? "";
return prev.map((r) =>
r.sessionId === sessionId ? { ...r, title: trimmed } : r,
);
});
try {
await window.hermesAPI.updateSessionTitle(sessionId, trimmed);
} catch (err) {
console.error("Failed to rename session", sessionId, err);
// Rollback optimistic update
setSessions((prev) =>
prev.map((s) =>
s.id === sessionId ? { ...s, title: oldSessionTitle } : s,
),
);
setSearchResults((prev) =>
prev.map((r) =>
r.sessionId === sessionId
? { ...r, title: oldSearchResultTitle }
: r,
),
);
}
// Guard: only clear editing state if the user hasn't started editing
// a different session while this request was in flight.
if (editingSessionIdRef.current === sessionId) {
setEditingSessionId(null);
setEditingTitle("");
}
},
[cancelRename],
[cancelRename, searchResults, sessions, t],
);

const cancelDelete = useCallback((): void => {
Expand Down
56 changes: 56 additions & 0 deletions src/renderer/src/screens/Sessions/confirmSessionRename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { RefObject } from "react";
import toast from "react-hot-toast";
import { normalizeSessionTitle } from "../../../../shared/session-title";

export interface ConfirmSessionRenameOptions {
sessionId: string;
value: string;
currentTitle: string;
/** True when this session is still the one being edited. */
isStillEditing: () => boolean;
applyOptimistic: (title: string) => void;
rollback: () => void;
clearEditing: () => void;
inputRef: RefObject<HTMLInputElement | null>;
fallbackErrorMessage: string;
persist: (sessionId: string, title: string) => Promise<void>;
}

export type ConfirmSessionRenameResult = "cancelled" | "saved" | "failed";

/**
* Shared inline-rename flow for the sidebar and Sessions modal: normalize,
* no-op cancel, optimistic update, durable persist, then clear editing on
* success or toast + rollback + refocus on failure.
*/
export async function confirmSessionRename(
opts: ConfirmSessionRenameOptions,
): Promise<ConfirmSessionRenameResult> {
const normalized = normalizeSessionTitle(opts.value);
if (!normalized || normalized === opts.currentTitle) {
opts.clearEditing();
return "cancelled";
}

opts.applyOptimistic(normalized);
try {
await opts.persist(opts.sessionId, normalized);
if (opts.isStillEditing()) opts.clearEditing();
return "saved";
} catch (err) {
console.error("Failed to rename session", opts.sessionId, err);
opts.rollback();
const message =
err instanceof Error && err.message
? err.message
: opts.fallbackErrorMessage;
toast.error(message);
if (opts.isStillEditing()) {
setTimeout(() => {
opts.inputRef.current?.focus();
opts.inputRef.current?.select();
}, 0);
}
return "failed";
}
}
6 changes: 6 additions & 0 deletions src/shared/i18n/locales/en/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export default {
messageSingular: "msg",
delete: "Delete conversation",
rename: "Rename conversation",
renameFailed: "Couldn't rename conversation",
renameInvalid: "Enter a valid name (max 100 characters)",
renameTooLong: "Title too long (max {{max}} characters)",
renameUnavailable: "Session database unavailable",
renameDuplicate: 'Title "{{title}}" is already in use',
renameNotFound: "Session not found",
deleteConfirmTitle: "Delete conversation",
deleteConfirm:
"Delete this conversation? This cannot be undone — both the messages and the session record will be permanently removed.",
Expand Down
Loading