anvilsign in

collin/strudel-claude

1// Multi-session store backed by localStorage. Each session holds its own
2// editor code + chat history. One session is "active" at a time.
3const KEY = 'strudel-claude-sessions-v1';
4const OLD_KEY = 'strudel-claude-v1'; // single-session format, migrated on first load
5
6const read = () => {
7 try {
8 return JSON.parse(localStorage.getItem(KEY) || 'null');
9 } catch {
10 return null;
11 }
12};
13const write = (state) => {
14 try {
15 localStorage.setItem(KEY, JSON.stringify(state));
16 } catch {
17 /* storage full/blocked — non-fatal */
18 }
19};
20
21let seq = 0;
22const makeId = () => 's' + Date.now().toString(36) + (seq++).toString(36);
23
24const newSession = (name, code, history = []) => ({
25 id: makeId(),
26 name: name || 'Untitled',
27 code,
28 history,
29 updatedAt: Date.now(),
30});
31
32// Load (and if needed, initialize/migrate) the store. `starter` is the default
33// code for a brand-new session.
34export function initStore(starter) {
35 let state = read();
36
37 // Drop order entries with no session behind them, and repair an activeId that
38 // points at a session that is gone — otherwise list() and getActive() throw on
39 // load and the restore appears to fail.
40 if (state?.sessions && Array.isArray(state.order)) {
41 const order = state.order.filter((id) => state.sessions[id]);
42 const activeId = state.sessions[state.activeId] ? state.activeId : order[order.length - 1];
43 if (order.length !== state.order.length || activeId !== state.activeId) {
44 state = { ...state, order, activeId };
45 write(state);
46 }
47 }
48
49 if (!state || !state.sessions || !state.order?.length) {
50 // Migrate an old single-session save if present.
51 let migrated = null;
52 try {
53 migrated = JSON.parse(localStorage.getItem(OLD_KEY) || 'null');
54 } catch {
55 /* ignore */
56 }
57 const first = migrated
58 ? newSession(nameFrom(migrated.history) || 'Restored', migrated.code || starter, migrated.history || [])
59 : newSession('Session 1', starter, []);
60 state = { activeId: first.id, order: [first.id], sessions: { [first.id]: first } };
61 localStorage.removeItem(OLD_KEY);
62 write(state);
63 }
64 return state;
65}
66
67// Derive a session name from the first user message.
68export function nameFrom(history) {
69 const firstUser = history?.find((m) => m.role === 'user')?.content?.trim();
70 if (!firstUser) return null;
71 return firstUser.length > 32 ? firstUser.slice(0, 32) + '…' : firstUser;
72}
73
74export function list() {
75 const s = read();
76 if (!s) return [];
77 return s.order.map((id) => ({ id, name: s.sessions[id].name, active: id === s.activeId }));
78}
79
80export const activeId = () => read()?.activeId;
81export const get = (id) => read()?.sessions[id];
82export const getActive = () => {
83 const s = read();
84 return s?.sessions[s.activeId];
85};
86
87export function setActive(id) {
88 const s = read();
89 if (s?.sessions[id]) {
90 s.activeId = id;
91 write(s);
92 }
93}
94
95// Persist a session's code + history. The caller passes the id of the session
96// it actually has loaded rather than letting this read `activeId`: another tab
97// (or a delete racing the autosave) can move `activeId` out from under us, and
98// writing to it would drop this editor's work into someone else's session.
99export function saveSession(id, { code, history }) {
100 const s = read();
101 const sess = s?.sessions[id];
102 if (!sess) return; // deleted elsewhere — nothing left to save into
103 sess.code = code;
104 sess.history = history;
105 // Auto-name from the first message while still untitled/default; a name the
106 // user typed (`named`) is never touched. Reverting the whole chat away drops
107 // back to Untitled so the next message can name the session again.
108 if (!sess.named) {
109 if (!history.length) {
110 if (!/^Session \d+$/.test(sess.name)) sess.name = 'Untitled';
111 } else if (/^(Untitled|Session \d+)$/.test(sess.name)) {
112 const derived = nameFrom(history);
113 if (derived) sess.name = derived;
114 }
115 }
116 sess.updatedAt = Date.now();
117 write(s);
118}
119
120// Rename a session. `named` pins it so auto-naming stops overwriting it;
121// clearing the name hands it back to auto-naming.
122export function rename(id, name) {
123 const s = read();
124 const sess = s?.sessions[id];
125 if (!sess) return;
126 const trimmed = name.trim();
127 sess.name = trimmed || nameFrom(sess.history) || 'Untitled';
128 sess.named = Boolean(trimmed);
129 write(s);
130}
131
132// Create a new (empty) session and make it active.
133export function create(starter) {
134 const s = read() ?? initStore(starter);
135 const sess = newSession('Untitled', starter, []);
136 s.sessions[sess.id] = sess;
137 s.order.push(sess.id);
138 s.activeId = sess.id;
139 write(s);
140 return sess;
141}
142
143// Delete a session; returns the session that becomes active afterward.
144export function remove(id, starter) {
145 const s = read() ?? initStore(starter);
146 if (!s.sessions[id]) return getActive();
147 delete s.sessions[id];
148 s.order = s.order.filter((x) => x !== id);
149 if (!s.order.length) {
150 const sess = newSession('Session 1', starter, []);
151 s.sessions[sess.id] = sess;
152 s.order.push(sess.id);
153 s.activeId = sess.id;
154 } else if (s.activeId === id) {
155 s.activeId = s.order[s.order.length - 1];
156 }
157 write(s);
158 return s.sessions[s.activeId];
159}