| 1 | // Multi-session store backed by localStorage. Each session holds its own |
| 2 | // editor code + chat history. One session is "active" at a time. |
| 3 | const KEY = 'strudel-claude-sessions-v1'; |
| 4 | const OLD_KEY = 'strudel-claude-v1'; // single-session format, migrated on first load |
| 5 | |
| 6 | const read = () => { |
| 7 | try { |
| 8 | return JSON.parse(localStorage.getItem(KEY) || 'null'); |
| 9 | } catch { |
| 10 | return null; |
| 11 | } |
| 12 | }; |
| 13 | const write = (state) => { |
| 14 | try { |
| 15 | localStorage.setItem(KEY, JSON.stringify(state)); |
| 16 | } catch { |
| 17 | /* storage full/blocked — non-fatal */ |
| 18 | } |
| 19 | }; |
| 20 | |
| 21 | let seq = 0; |
| 22 | const makeId = () => 's' + Date.now().toString(36) + (seq++).toString(36); |
| 23 | |
| 24 | const 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. |
| 34 | export 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. |
| 68 | export 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 | |
| 74 | export 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 | |
| 80 | export const activeId = () => read()?.activeId; |
| 81 | export const get = (id) => read()?.sessions[id]; |
| 82 | export const getActive = () => { |
| 83 | const s = read(); |
| 84 | return s?.sessions[s.activeId]; |
| 85 | }; |
| 86 | |
| 87 | export 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. |
| 99 | export 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. |
| 122 | export 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. |
| 133 | export 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. |
| 144 | export 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 | } |