Skip to content

Commit 1eab53d

Browse files
authored
fix: keep composer draft when a session's pane closes and reopens (hardbeat920#336)
SessionPane kept the text you were typing in a component-local ref (draftRef). That ref only lives as long as one mounted instance of SessionPane, so closing a session's pane (or otherwise unmounting it) threw the draft away with no warning - the next time the pane opened, draftRef started at undefined again. This add a small module-level cache (draftCache.ts) keyed by session id, outside the React tree, so it survives a SessionPane instance being unmounted and remounted for as long as the app keeps running. SessionPane now seeds draftRef from it and writes to it on every change, same place it already updates draftRef. This does not make the draft survive a full app restart - that needs the draft written into the session's own persisted record on disk (session_upsert on the Rust side), a bigger and separate change. Fixes hardbeat920#270.
1 parent 0eb64c9 commit 1eab53d

3 files changed

Lines changed: 84 additions & 1 deletion

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
clearComposerDraft,
4+
getComposerDraft,
5+
setComposerDraft,
6+
} from "./draftCache";
7+
8+
describe("draftCache", () => {
9+
it("returns undefined for a session that never had a draft", () => {
10+
expect(getComposerDraft("never-seen")).toBeUndefined();
11+
});
12+
13+
it("gives back the last text set for a session, across separate reads", () => {
14+
// This is the actual bug: SessionPane used to keep the composer's text
15+
// in a component-local ref, so closing a session's pane (unmounting
16+
// SessionPane) and reopening it - a fresh component instance calling
17+
// getComposerDraft again - lost whatever was typed. A module-level
18+
// cache has to answer the same text back on a later, independent read.
19+
setComposerDraft("s1", "half-typed message");
20+
21+
expect(getComposerDraft("s1")).toBe("half-typed message");
22+
});
23+
24+
it("keeps drafts for different sessions apart", () => {
25+
setComposerDraft("s2", "draft for session two");
26+
setComposerDraft("s3", "draft for session three");
27+
28+
expect(getComposerDraft("s2")).toBe("draft for session two");
29+
expect(getComposerDraft("s3")).toBe("draft for session three");
30+
});
31+
32+
it("treats setting an empty string as clearing the draft", () => {
33+
setComposerDraft("s4", "something");
34+
setComposerDraft("s4", "");
35+
36+
expect(getComposerDraft("s4")).toBeUndefined();
37+
});
38+
39+
it("clearComposerDraft removes a stored draft", () => {
40+
setComposerDraft("s5", "will be cleared");
41+
clearComposerDraft("s5");
42+
43+
expect(getComposerDraft("s5")).toBeUndefined();
44+
});
45+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* In-memory composer drafts, keyed by session id.
3+
*
4+
* SessionPane keeps the text you are typing in a React ref so retyping while
5+
* the pane stays mounted does not re-render on every keystroke. A plain ref
6+
* only lives as long as that one component instance though: closing a
7+
* session's pane (or moving it to another split) unmounts SessionPane, and
8+
* the ref - and whatever you had typed - is gone with no warning the moment
9+
* it remounts.
10+
*
11+
* This module-level map survives that, because it lives outside the React
12+
* tree for as long as the app process is running: the draft comes back when
13+
* the pane for that session opens again.
14+
*
15+
* It does not survive an app restart or a full reload - that needs the
16+
* draft written into the session's persisted record on disk, which is a
17+
* separate, larger change (touches the Rust-side session_upsert schema
18+
* too).
19+
*/
20+
const drafts = new Map<string, string>();
21+
22+
export function getComposerDraft(sessionId: string): string | undefined {
23+
return drafts.get(sessionId);
24+
}
25+
26+
export function setComposerDraft(sessionId: string, text: string): void {
27+
if (text) {
28+
drafts.set(sessionId, text);
29+
} else {
30+
drafts.delete(sessionId);
31+
}
32+
}
33+
34+
export function clearComposerDraft(sessionId: string): void {
35+
drafts.delete(sessionId);
36+
}

‎src/features/sessions/ui/SessionPane.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
} from "../model/quoteDraft";
5353
import { createNote, noteTitle } from "../../notes";
5454
import { loadNotesEnabled, subscribeNotesEnabled } from "../../settings/model/settings";
55+
import { getComposerDraft, setComposerDraft } from "../model/draftCache";
5556
import { resolveModel } from "../model/models";
5657
import { isAstraModel } from "../model/astraWelcome";
5758
import { AstraWelcome } from "./AstraWelcome";
@@ -365,7 +366,7 @@ export const SessionPane = memo(function SessionPane({
365366
const showDeckProjectPicker = isEmpty && !looksLikeProject(session.cwd);
366367
const dockComposer =
367368
!draftBlock && (!isEmpty || inSplit || !!session.inboxAsk);
368-
const draftRef = useRef<string | undefined>(undefined);
369+
const draftRef = useRef<string | undefined>(getComposerDraft(session.id));
369370
const composer = (
370371
<Composer
371372
enabled={visible}
@@ -398,6 +399,7 @@ export const SessionPane = memo(function SessionPane({
398399
}
399400
onDraftChange={(text) => {
400401
draftRef.current = text;
402+
setComposerDraft(session.id, text);
401403
}}
402404
inboxCard={session.inboxCard}
403405
noteCard={session.noteCard}

0 commit comments

Comments
 (0)