@@ -53,6 +53,7 @@ public static function create_table(): void {
5353 context VARCHAR(20) NOT NULL DEFAULT 'chat' COMMENT 'Execution context: chat, pipeline, system',
5454 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5555 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
56+ last_read_at DATETIME NULL COMMENT 'When the user last read this session',
5657 expires_at DATETIME NULL COMMENT 'Auto-cleanup timestamp',
5758 PRIMARY KEY (session_id),
5859 KEY user_id (user_id),
@@ -145,6 +146,31 @@ public static function ensure_context_column(): void {
145146 }
146147 }
147148
149+ /**
150+ * Ensure last_read_at column exists for unread message tracking.
151+ *
152+ * dbDelta can miss edge cases on existing installs, so we perform an explicit
153+ * column check and ALTER as a safety net.
154+ *
155+ * @since 0.62.0
156+ * @return void
157+ */
158+ public static function ensure_last_read_at_column (): void {
159+ global $ wpdb ;
160+
161+ $ table_name = self ::get_prefixed_table_name ();
162+
163+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
164+ $ column = $ wpdb ->get_var ( $ wpdb ->prepare ( 'SHOW COLUMNS FROM %i LIKE %s ' , $ table_name , 'last_read_at ' ) );
165+
166+ if ( $ column ) {
167+ return ;
168+ }
169+
170+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
171+ $ wpdb ->query ( $ wpdb ->prepare ( 'ALTER TABLE %i ADD COLUMN last_read_at DATETIME NULL AFTER updated_at ' , $ table_name ) );
172+ }
173+
148174 /**
149175 * Check if table exists
150176 *
@@ -514,12 +540,15 @@ public function get_user_sessions(
514540 }
515541 }
516542
543+ $ last_read_at = $ session ['last_read_at ' ] ?? null ;
544+
517545 $ result [] = array (
518546 'session_id ' => $ session ['session_id ' ],
519547 'title ' => $ session ['title ' ] ?? null ,
520548 'context ' => $ session ['context ' ] ?? 'chat ' ,
521549 'first_message ' => mb_substr ( $ first_message , 0 , 100 ),
522550 'message_count ' => count ( $ messages ),
551+ 'unread_count ' => $ this ->count_unread ( $ messages , $ last_read_at ),
523552 'created_at ' => DateFormatter::format_for_api ( $ session ['created_at ' ] ?? null ),
524553 'updated_at ' => DateFormatter::format_for_api ( $ session ['updated_at ' ] ?? $ session ['created_at ' ] ?? null ),
525554 );
@@ -689,6 +718,91 @@ public function update_title( string $session_id, string $title ): bool {
689718 return true ;
690719 }
691720
721+ /**
722+ * Count unread assistant messages in a session.
723+ *
724+ * Counts assistant messages whose metadata.timestamp is newer than
725+ * the given last_read_at value. If last_read_at is NULL, all assistant
726+ * messages are considered unread.
727+ *
728+ * @since 0.62.0
729+ *
730+ * @param array $messages Decoded messages array from the session.
731+ * @param string|null $last_read_at ISO 8601 or MySQL datetime string, or null if never read.
732+ * @return int Number of unread assistant messages.
733+ */
734+ public function count_unread ( array $ messages , ?string $ last_read_at ): int {
735+ $ count = 0 ;
736+
737+ foreach ( $ messages as $ msg ) {
738+ if ( ( $ msg ['role ' ] ?? '' ) !== 'assistant ' ) {
739+ continue ;
740+ }
741+
742+ // Skip tool call/result messages — only count visible assistant responses.
743+ $ type = $ msg ['metadata ' ]['type ' ] ?? 'text ' ;
744+ if ( 'tool_call ' === $ type || 'tool_result ' === $ type ) {
745+ continue ;
746+ }
747+
748+ if ( null === $ last_read_at ) {
749+ ++$ count ;
750+ continue ;
751+ }
752+
753+ $ timestamp = $ msg ['metadata ' ]['timestamp ' ] ?? null ;
754+ if ( $ timestamp && strtotime ( $ timestamp ) > strtotime ( $ last_read_at ) ) {
755+ ++$ count ;
756+ }
757+ }
758+
759+ return $ count ;
760+ }
761+
762+ /**
763+ * Mark a session as read by setting last_read_at to the current time.
764+ *
765+ * @since 0.62.0
766+ *
767+ * @param string $session_id Session UUID.
768+ * @param int $user_id User ID for ownership verification.
769+ * @return string|false The new last_read_at value on success, false on failure.
770+ */
771+ public function mark_session_read ( string $ session_id , int $ user_id ) {
772+ global $ wpdb ;
773+
774+ $ table_name = self ::get_prefixed_table_name ();
775+ $ last_read_at = current_time ( 'mysql ' , true );
776+
777+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
778+ $ result = $ wpdb ->update (
779+ $ table_name ,
780+ array ( 'last_read_at ' => $ last_read_at ),
781+ array (
782+ 'session_id ' => $ session_id ,
783+ 'user_id ' => $ user_id ,
784+ ),
785+ array ( '%s ' ),
786+ array ( '%s ' , '%d ' )
787+ );
788+
789+ if ( false === $ result ) {
790+ do_action (
791+ 'datamachine_log ' ,
792+ 'error ' ,
793+ 'Failed to mark chat session as read ' ,
794+ array (
795+ 'session_id ' => $ session_id ,
796+ 'user_id ' => $ user_id ,
797+ 'error ' => $ wpdb ->last_error ,
798+ )
799+ );
800+ return false ;
801+ }
802+
803+ return $ last_read_at ;
804+ }
805+
692806 /**
693807 * Cleanup old sessions based on retention period
694808 *
0 commit comments