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
10 changes: 10 additions & 0 deletions lat.md/sidebar-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,13 @@ Settings boolean preferences use one accessible controlled switch with consisten
Fresh chat session ids are provisional until a turn produces output or completes successfully, so provider errors do not create visible recent-session rows.

The main-process transports still send a generated `X-Hermes-Session-Id` on fresh requests to avoid gateway fingerprint collisions, but [[src/main/hermes.ts#sendMessageViaApi]] and the runs transport announce that id to the renderer only after visible output, tool/reasoning activity, or successful completion. Resumed sessions are announced immediately because the renderer already knows they are existing conversations. This keeps [[src/renderer/src/screens/Chat/hooks/useChatIPC.ts#useChatIPC]] from binding a failed first turn to a new sidebar entry.

### Live first-turn rows

The gateway transport now un-provisions a fresh session id at that same moment, so a long first run is listed in the sidebar while it is still generating instead of only after it finishes.

[[src/main/hermes.ts#sendMessageViaTuiGateway]] funnels the three signals that mean the turn is really under way (a message delta, a reasoning delta, and a tool event) through one helper that both records that output was seen and announces the stored session id. The stored id is the one announced because it is what `finish` reports and what `state.db` is keyed by, so the renderer and the session cache agree on which row to create.

[[src/renderer/src/screens/Layout/SidebarRecentSessions.tsx#needsForcedSessionSync]] reports an open conversation that belongs to a run still generating and has no loaded row, and the refresh that normally follows a session switch skips the throttle for exactly that case: the click that opened "New Chat" is usually still inside the five-second window, so a throttled refresh is dropped and the new row stays invisible until the sixty-second poll. The live-run requirement keeps older conversations out of it, since a resumed session is equally absent from the loaded page but must not turn every switch into a full `state.db` read. Every other refresh stays throttled.

[[src/renderer/src/screens/Layout/SidebarRecentSessions.test.tsx]] locks both directions (a live run with no loaded row forces an immediate sync and the row then renders; a listed or merely unlisted resumed session does not), and [[tests/gateway-session-announce.test.ts]] covers the gateway announcing once, on first activity rather than at session creation.
23 changes: 20 additions & 3 deletions src/main/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2181,6 +2181,23 @@ async function sendMessageViaTuiGateway(
// answer. Cleared on turn end so an abandoned turn leaks no stale resolver.
let pendingClarifyId: string | null = null;
const pendingApprovalIds = new Set<string>();
let announcedSessionId = "";

// @lat: [[sidebar-navigation#Provisional fresh sessions#Live first-turn rows]]
//
// First real turn activity (visible text, reasoning, or a tool event) is
// also the moment a fresh session id stops being provisional, exactly as
// in sendMessageViaApi. Announcing here instead of waiting for finish() is
// what puts a still-running first turn on the sidebar list (#980), while a
// turn that dies before producing anything still leaves no row behind. The
// stored id is announced because that is the one finish() reports and the
// one state.db (and therefore the session cache) is keyed by.
function noteGatewayActivity(): void {
hasGatewayOutput = true;
if (!storedSessionId || announcedSessionId === storedSessionId) return;
announcedSessionId = storedSessionId;
cb.onSessionStarted?.(storedSessionId);
}

function clearApprovals(): void {
for (const requestId of pendingApprovalIds) {
Expand Down Expand Up @@ -2274,21 +2291,21 @@ async function sendMessageViaTuiGateway(
const delta = gatewayMessageDelta(event);
if (delta) {
streamedText += delta;
hasGatewayOutput = true;
noteGatewayActivity();
cb.onChunk(delta);
return;
}

const reasoning = gatewayReasoningText(event);
if (reasoning && cb.onReasoningChunk) {
hasGatewayOutput = true;
noteGatewayActivity();
cb.onReasoningChunk(reasoning);
return;
}

const toolEvent = gatewayToolEvent(event);
if (toolEvent) {
hasGatewayOutput = true;
noteGatewayActivity();
if (cb.onToolEvent) {
cb.onToolEvent(toolEvent);
} else if (cb.onToolProgress) {
Expand Down
166 changes: 166 additions & 0 deletions src/renderer/src/screens/Layout/SidebarRecentSessions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type React from "react";
import SidebarRecentSessions, {
needsForcedSessionSync,
} from "./SidebarRecentSessions";

// The list only needs `t` for section headers and row labels.
vi.mock("../../components/useI18n", () => ({
useI18n: () => ({
t: (key: string): string => key,
}),
}));

interface CachedRow {
id: string;
title: string;
contextFolder: string | null;
}

function installHermesAPI(rows: CachedRow[] = []): {
listCachedSessions: ReturnType<typeof vi.fn>;
syncSessionCache: ReturnType<typeof vi.fn>;
} {
const api = {
listCachedSessions: vi.fn().mockResolvedValue(rows),
syncSessionCache: vi.fn().mockResolvedValue(rows),
};
Object.defineProperty(window, "hermesAPI", {
configurable: true,
value: api,
});
return api;
}

const baseProps = {
open: true,
connectionId: "connection-one",
activeProfile: "work",
resumingSessionId: null,
onSelect: (): void => {},
scrollRootRef: { current: null } as React.RefObject<HTMLDivElement | null>,
};

beforeEach(() => {
localStorage.clear();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe("needsForcedSessionSync", () => {
it("holds only for a live run whose session has no loaded row", () => {
expect(needsForcedSessionSync("s1", [], new Set(["s1"]))).toBe(true);
expect(
needsForcedSessionSync("s1", [{ id: "s0" }], new Set(["s1"])),
).toBe(true);
expect(
needsForcedSessionSync("s1", [{ id: "s1" }], new Set(["s1"])),
).toBe(false);
});

it("does not hold for a resumed session that is simply past the loaded page", () => {
expect(needsForcedSessionSync("s1", [{ id: "s0" }], new Set())).toBe(false);
});

it("does not hold without an open conversation", () => {
expect(needsForcedSessionSync(null, [], new Set(["s1"]))).toBe(false);
});
});

describe("sidebar session cache sync (#980)", () => {
it("syncs past the throttle and lists a session a live run just created", async () => {
const api = installHermesAPI();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
let cached: CachedRow[] = [];
api.syncSessionCache.mockImplementation(() => Promise.resolve(cached));

const { rerender } = render(
<SidebarRecentSessions
{...baseProps}
currentSessionId={null}
loadingSessionIds={new Set()}
/>,
);
// Settle the opening cache read and sync, then let the throttle arm.
await act(async () => {});
const atMount = api.syncSessionCache.mock.calls.length;
expect(atMount).toBeGreaterThan(0);
expect(screen.queryByText("Look at the capabilities")).toBeNull();

// The gateway hands out the session id and the first user message is stored,
// so the forced sync finds a titled row.
cached = [
{ id: "session-new", title: "Look at the capabilities", contextFolder: null },
];
rerender(
<SidebarRecentSessions
{...baseProps}
currentSessionId="session-new"
loadingSessionIds={new Set(["session-new"])}
/>,
);

await vi.waitFor(() =>
expect(api.syncSessionCache.mock.calls.length).toBe(atMount + 1),
);
expect(await screen.findByText("Look at the capabilities")).toBeTruthy();
});

it("keeps the throttle when switching to a loaded session", async () => {
const api = installHermesAPI([
{ id: "session-listed", title: "Listed", contextFolder: null },
]);

const { rerender } = render(
<SidebarRecentSessions
{...baseProps}
currentSessionId={null}
loadingSessionIds={new Set()}
/>,
);
await act(async () => {});
const atMount = api.syncSessionCache.mock.calls.length;
expect(atMount).toBeGreaterThan(0);

rerender(
<SidebarRecentSessions
{...baseProps}
currentSessionId="session-listed"
loadingSessionIds={new Set()}
/>,
);

expect(api.syncSessionCache.mock.calls.length).toBe(atMount);
});

it("keeps the throttle when resuming a session past the loaded page", async () => {
const api = installHermesAPI([
{ id: "session-first", title: "First", contextFolder: null },
]);

const { rerender } = render(
<SidebarRecentSessions
{...baseProps}
currentSessionId={null}
loadingSessionIds={new Set()}
/>,
);
await act(async () => {});
const atMount = api.syncSessionCache.mock.calls.length;
expect(atMount).toBeGreaterThan(0);

// An old conversation that is not in the loaded page: resuming it must not
// turn every switch into a full state.db read.
rerender(
<SidebarRecentSessions
{...baseProps}
currentSessionId="session-deep"
loadingSessionIds={new Set()}
/>,
);

expect(api.syncSessionCache.mock.calls.length).toBe(atMount);
});
});
45 changes: 43 additions & 2 deletions src/renderer/src/screens/Layout/SidebarRecentSessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,33 @@ interface RecentSession {
// ChatGPT-style paged conversation list under the pinned app navigation.
export const RECENT_SESSIONS_PAGE_SIZE = 30;

// @lat: [[sidebar-navigation#Provisional fresh sessions#Live first-turn rows]]
/**
* True when the open conversation belongs to a run that is still generating
* and the loaded page has no row for it, so its refresh must skip the
* throttle.
*
* A run's session id is revealed the moment the agent produces its first
* visible token, reasoning, or tool call, so the sidebar can be showing a
* chat it cannot list yet. The refresh that would pick the row up is normally
* throttled, and the click that opened "New Chat" usually still sits inside
* that window, which is what kept a new conversation out of the list for the
* rest of its first run (issue #980).
*
* The live-run requirement is what keeps an older resumed conversation out of
* it: such a session is also missing from the loaded page, simply because it
* sits past the first rows, and forcing a full sync for every switch into one
* would defeat the throttle the cache read exists to respect.
*/
export function needsForcedSessionSync(
sessionId: string | null,
listed: ReadonlyArray<{ id: string }>,
liveSessionIds: ReadonlySet<string>,
): boolean {
if (!sessionId || !liveSessionIds.has(sessionId)) return false;

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.

P2 Fast turns miss sidebar refresh

If a first turn finishes before the renderer processes its announced session ID, the run is no longer in loadingSessionIds. This check then treats the unlisted session as ineligible for a forced refresh, so the five-second throttle can drop the refresh and leave the row absent until a focus event or the 60-second poll.

return !listed.some((row) => row.id === sessionId);
}

// Re-sync cadence while the list is visible. Deliberately slower than the
// Sessions screen (30s) — the sidebar is always on screen, so this interval
// runs for the whole app lifetime when the section is expanded.
Expand Down Expand Up @@ -318,6 +345,15 @@ const SidebarRecentSessions = memo(function SidebarRecentSessions({
[normalizeRows],
);

// A conversation whose first run is still generating can be missing from the
// loaded page; that is the one case the refresh throttle below must not
// swallow.
const forceSessionSync = useMemo(
() =>
needsForcedSessionSync(currentSessionId, sessions, loadingSessionIds),
[currentSessionId, loadingSessionIds, sessions],
);

const refresh = useCallback(
async (force = false): Promise<void> => {
const now = Date.now();
Expand Down Expand Up @@ -450,9 +486,14 @@ const SidebarRecentSessions = memo(function SidebarRecentSessions({
// Resuming/switching sessions reorders recency — refresh (throttled).
// Also refreshes when going to "New Chat" (currentSessionId becomes null)
// so the just-left session appears in the list immediately.
//
// A live run with no loaded row skips the throttle: the click that opened
// "New Chat" is usually still inside the 5s window, so a throttled refresh
// here is dropped and the new row stays invisible until the 60s poll (issue
// #980). Every other session switch stays throttled.
useEffect(() => {
if (open) void refresh();
}, [open, currentSessionId, refresh]);
if (open) void refresh(forceSessionSync);
}, [forceSessionSync, open, currentSessionId, refresh]);

// Switching agent points the list at a different profile's DB. Force a
// reload immediately (bypassing the throttle) so the list isn't stale.
Expand Down
Loading