* Owns the thread catalog in localStorage. This is the layer the persistence * adapter doesn't provide: the list of conversations, their titles, and recency.
()
| 165 | * adapter doesn't provide: the list of conversations, their titles, and recency. |
| 166 | */ |
| 167 | function useThreadIndex() { |
| 168 | const [threads, setThreads] = useState<Array<ThreadMeta>>([]) |
| 169 | // SSR-safety: the server has no localStorage, so we render an empty list and |
| 170 | // load the real catalog on the client after mount. `loaded` gates bootstrap |
| 171 | // logic so we don't auto-create a spurious thread before the real one loads. |
| 172 | const [loaded, setLoaded] = useState(false) |
| 173 | // Mirror of the catalog read synchronously by mutators. State closures are |
| 174 | // stale between two calls in the same event handler (e.g. set-title then |
| 175 | // bump-recency), which would make the second call clobber the first; the ref |
| 176 | // is always current, so sequential mutations compose correctly. |
| 177 | const threadsRef = useRef<Array<ThreadMeta>>([]) |
| 178 | |
| 179 | useEffect(() => { |
| 180 | const initial = readIndex().sort(byRecency) |
| 181 | threadsRef.current = initial |
| 182 | setThreads(initial) |
| 183 | setLoaded(true) |
| 184 | }, []) |
| 185 | |
| 186 | const commit = (next: Array<ThreadMeta>) => { |
| 187 | const sorted = [...next].sort(byRecency) |
| 188 | threadsRef.current = sorted |
| 189 | setThreads(sorted) |
| 190 | writeIndex(sorted) |
| 191 | } |
| 192 | |
| 193 | const createThread = (): ThreadMeta => { |
| 194 | const thread: ThreadMeta = { |
| 195 | id: crypto.randomUUID(), |
| 196 | title: NEW_CHAT_TITLE, |
| 197 | updatedAt: Date.now(), |
| 198 | } |
| 199 | commit([thread, ...threadsRef.current]) |
| 200 | return thread |
| 201 | } |
| 202 | |
| 203 | const deleteThread = (id: string) => { |
| 204 | threadPersistence.removeItem(id) |
| 205 | commit(threadsRef.current.filter((t) => t.id !== id)) |
| 206 | } |
| 207 | |
| 208 | /** Bump recency, and set the title the first time a thread gets one. */ |
| 209 | const touchThread = (id: string, title?: string) => { |
| 210 | commit( |
| 211 | threadsRef.current.map((t) => |
| 212 | t.id === id |
| 213 | ? { |
| 214 | ...t, |
| 215 | updatedAt: Date.now(), |
| 216 | title: |
| 217 | title && t.title === NEW_CHAT_TITLE |
| 218 | ? truncateTitle(title) |
| 219 | : t.title, |
| 220 | } |
| 221 | : t, |
| 222 | ), |
| 223 | ) |
| 224 | } |
no test coverage detected