updates

ober

fd8d867a4db51d1f32e4281fe60a3a9818b75d53

diff --git a/Makefile b/Makefile
index 4fbbb97..6c60559 100644
--- a/Makefile
+++ b/Makefile
@@ -56,7 +56,9 @@ run-tui: binary tui-shim
 test: binary
 	$(JEXEC) tests/test-send-result.ss
 	$(JEXEC) tests/test-rpc-demux.ss
+	$(JEXEC) tests/test-logdb-jsqlite.ss
 	$(JEXEC) tests/test-receive-normalization.ss
+	$(JEXEC) tests/test-history-replay.ss
 	./$(BIN) --help >/dev/null && echo "smoke ok"
 
 install: binary
diff --git a/signal/tui/main.ss b/signal/tui/main.ss
index 9cafed8..9714e64 100644
--- a/signal/tui/main.ss
+++ b/signal/tui/main.ss
@@ -5,6 +5,7 @@
   (export run-tui-terminal
           run-tui-terminal-with-logdb
           open-tui-message-log
+          history-replay-snapshot
           notification->chat-event
           notification->line)
 
@@ -72,29 +73,13 @@
         (let ([theme-name (or (load-theme-name) "opencode-dark")])
           (unless (set-theme-by-name! theme-name)
             (set-theme-by-name! "opencode-dark")))
-        (let ([state (make-tui-state acct
-                                     version
-                                     receive-mode
-                                     (tb-width)
-                                     (tb-height)
-                                     ""
-                                     "Backend connected."
-                                     #f
-                                     0
-                                     (list
-                                       (make-system-conversation
-                                         (list
-                                           "TUI connected. Waiting for Signal receive notifications.")))
-                                     0
-                                     'chat
-                                     ""
-                                     0
-                                     removed
-                                     (make-hashtable equal-hash equal?)
-                                     logdb
-                                     #f
-                                     #f
-                                     (make-hashtable equal-hash equal?))])
+        (let ([state (make-initial-tui-state acct
+                                             version
+                                             receive-mode
+                                             (tb-width)
+                                             (tb-height)
+                                             logdb
+                                             removed)])
           (append-system-message!
             state
             (if logdb
@@ -107,6 +92,31 @@
           (preload-history! state)
           (event-loop state actor)))))
 
+  (def (make-initial-tui-state acct version receive-mode width height logdb removed)
+    (make-tui-state acct
+                    version
+                    receive-mode
+                    width
+                    height
+                    ""
+                    "Backend connected."
+                    #f
+                    0
+                    (list
+                      (make-system-conversation
+                        (list
+                          "TUI connected. Waiting for Signal receive notifications.")))
+                    0
+                    'chat
+                    ""
+                    0
+                    removed
+                    (make-hashtable equal-hash equal?)
+                    logdb
+                    #f
+                    #f
+                    (make-hashtable equal-hash equal?)))
+
   ;; Open the encrypted message log. Passphrase comes from JERBOA_SIGNAL_DB_KEY
   ;; if set (enables headless use), otherwise we prompt. A blank passphrase or a
   ;; missing shim disables logging and the TUI runs normally.
@@ -131,7 +141,7 @@
 
   (def (preload-history! state)
     (let ([logdb (tui-state-logdb state)])
-      (when logdb
+      (if logdb
         (let loop ([rows (logdb-recent logdb *history-preload-limit*)]
                    [count 0])
           (cond
@@ -141,34 +151,120 @@
                  state
                  (string-append "Restored "
                                 (number->string count)
-                                " messages from the encrypted log.")))]
+                                " messages from the encrypted log.")))
+             count]
             [else
              (loop (cdr rows)
-                   (+ count (if (apply-history-row! state (car rows)) 1 0)))])))))
+                   (+ count (if (apply-history-row! state (car rows)) 1 0)))]))
+        0)))
+
+  ;; Non-interactive diagnostic/test helper: rebuild the same initial state used
+  ;; by the TUI and replay encrypted log rows into it without opening termbox or
+  ;; signal-cli. Returns (restored-count conversation-snapshots).
+  (def (history-replay-snapshot logdb)
+    (let* ([state (make-initial-tui-state "test"
+                                          "test"
+                                          "manual"
+                                          80
+                                          24
+                                          logdb
+                                          (make-hashtable equal-hash equal?))]
+           [count (preload-history! state)])
+      (list count
+            (conversation-history-snapshots
+              (tui-state-conversations state)))))
+
+  (def (conversation-history-snapshots conversations)
+    (let loop ([xs conversations] [acc '()])
+      (cond
+        [(null? xs) (reverse acc)]
+        [(string=? (conversation-id (car xs)) "system")
+         (loop (cdr xs) acc)]
+        [else
+         (loop (cdr xs)
+               (cons (conversation-history-snapshot (car xs)) acc))])))
+
+  (def (conversation-history-snapshot conv)
+    (list (conversation-id conv)
+          (conversation-title conv)
+          (conversation-kind conv)
+          (conversation-target conv)
+          (let loop ([msgs (conversation-messages conv)] [acc '()])
+            (if (null? msgs)
+              (reverse acc)
+              (loop (cdr msgs)
+                    (cons (chat-message-history-snapshot (car msgs)) acc))))))
+
+  (def (chat-message-history-snapshot msg)
+    (list (chat-message-direction msg)
+          (chat-message-sender msg)
+          (chat-message-text msg)
+          (chat-message-timestamp msg)
+          (chat-message-kind msg)
+          (chat-message-status msg)))
 
   ;; row = (direction conversation sender timestamp kind body); see logdb-recent.
   (def (apply-history-row! state row)
-    (let* ([direction (list-ref row 0)]
-           [conv-id (canonical-conversation-id state (list-ref row 1))]
-           [sender (list-ref row 2)]
-           [ts (list-ref row 3)]
-           [body (list-ref row 5)]
-           [kind-target (parse-conversation-id conv-id)])
-      (and kind-target
-           (not (conversation-removed? state conv-id))
-           (let* ([out? (string=? direction "out")]
-                  [conv (ensure-conversation! state conv-id ""
-                                              (car kind-target)
-                                              (cdr kind-target))]
-                  [msg (make-chat-message (if out? 'out 'in)
-                                          (if out? "You" sender)
-                                          body
-                                          (and (number? ts) (> ts 0) ts)
-                                          'data
-                                          #f)])
+    (let ([ev (history-row->message-event state row)])
+      (and ev
+           (let* ([id (list-ref ev 0)]
+                  [title (list-ref ev 1)]
+                  [kind (list-ref ev 2)]
+                  [target (list-ref ev 3)]
+                  [msg (list-ref ev 4)]
+                  [conv (ensure-conversation! state id title kind target)])
              (append-message-to-conversation! conv msg)
              #t))))
 
+  (def (history-row->message-event state row)
+    (and (list? row)
+         (>= (length row) 6)
+         (let* ([direction (list-ref row 0)]
+                [conv-id (canonical-conversation-id state (list-ref row 1))]
+                [sender (list-ref row 2)]
+                [ts (list-ref row 3)]
+                [kind-text (list-ref row 4)]
+                [body (list-ref row 5)]
+                [kind-target (parse-conversation-id conv-id)])
+           (and kind-target
+                (not (conversation-removed? state conv-id))
+                (let* ([out? (string=? direction "out")]
+                       [kind (car kind-target)]
+                       [target (cdr kind-target)]
+                       [title (history-row-title state conv-id kind target
+                                                 sender out?)]
+                       [msg (make-chat-message (if out? 'out 'in)
+                                               (if out? "You" sender)
+                                               body
+                                               (and (number? ts) (> ts 0) ts)
+                                               (history-kind-symbol kind-text)
+                                               #f)])
+                  (list conv-id title kind target msg))))))
+
+  (def (history-row-title state conv-id kind target sender out?)
+    (let ([existing (find-conversation (tui-state-conversations state) conv-id)])
+      (if existing
+        ""
+        (cond
+          [(and (eq? kind 'direct) (not out?) (non-empty-string? sender))
+           sender]
+          [(eq? kind 'group)
+           (string-append "Group " (short-id target))]
+          [(non-empty-string? target) target]
+          [else conv-id]))))
+
+  (def (history-kind-symbol kind)
+    (cond
+      [(not (string? kind)) 'data]
+      [(string=? kind "edit") 'edit]
+      [(string=? kind "remote-delete") 'remote-delete]
+      [(string=? kind "reaction") 'reaction]
+      [(string=? kind "sticker") 'sticker]
+      [(string=? kind "contact") 'contact]
+      [(string=? kind "preview") 'preview]
+      [(string=? kind "attachment") 'attachment]
+      [else 'data]))
+
   ;; "direct:<target>" / "group:<id>" -> (kind . target), #f for anything else.
   (def (parse-conversation-id id)
     (cond