@@ -707,4 +707,216 @@ export const chatsRouter = router({
707707 )
708708 }
709709 } ) ,
710+
711+ /**
712+ * Get file change stats for all workspaces
713+ * Parses messages from all sub-chats and aggregates Edit/Write tool calls
714+ * If openSubChatIds provided, only count stats from those sub-chats
715+ */
716+ getFileStats : publicProcedure
717+ . input ( z . object ( { openSubChatIds : z . array ( z . string ( ) ) . optional ( ) } ) . optional ( ) )
718+ . query ( ( { input } ) => {
719+ const db = getDatabase ( )
720+ const openSubChatIdsSet = input ?. openSubChatIds ? new Set ( input . openSubChatIds ) : null
721+
722+ // Get all non-archived chats with their sub-chats
723+ const allChats = db
724+ . select ( {
725+ chatId : chats . id ,
726+ subChatId : subChats . id ,
727+ messages : subChats . messages ,
728+ } )
729+ . from ( chats )
730+ . leftJoin ( subChats , eq ( subChats . chatId , chats . id ) )
731+ . where ( isNull ( chats . archivedAt ) )
732+ . all ( )
733+ // Filter by open sub-chats if provided
734+ . filter ( row => ! openSubChatIdsSet || ! row . subChatId || openSubChatIdsSet . has ( row . subChatId ) )
735+
736+ // Aggregate stats per workspace (chatId)
737+ const statsMap = new Map <
738+ string ,
739+ { additions : number ; deletions : number ; fileCount : number }
740+ > ( )
741+
742+ for ( const row of allChats ) {
743+ if ( ! row . messages || ! row . chatId ) continue
744+
745+ try {
746+ const messages = JSON . parse ( row . messages ) as Array < {
747+ role : string
748+ parts ?: Array < {
749+ type : string
750+ input ?: {
751+ file_path ?: string
752+ old_string ?: string
753+ new_string ?: string
754+ content ?: string
755+ }
756+ } >
757+ } >
758+
759+ // Track file states for this sub-chat
760+ const fileStates = new Map <
761+ string ,
762+ { originalContent : string | null ; currentContent : string }
763+ > ( )
764+
765+ for ( const msg of messages ) {
766+ if ( msg . role !== "assistant" ) continue
767+ for ( const part of msg . parts || [ ] ) {
768+ if ( part . type === "tool-Edit" || part . type === "tool-Write" ) {
769+ const filePath = part . input ?. file_path
770+ if ( ! filePath ) continue
771+ // Skip session files
772+ if (
773+ filePath . includes ( "claude-sessions" ) ||
774+ filePath . includes ( "Application Support" )
775+ )
776+ continue
777+
778+ const oldString = part . input ?. old_string || ""
779+ const newString =
780+ part . input ?. new_string || part . input ?. content || ""
781+
782+ const existing = fileStates . get ( filePath )
783+ if ( existing ) {
784+ existing . currentContent = newString
785+ } else {
786+ fileStates . set ( filePath , {
787+ originalContent : part . type === "tool-Write" ? null : oldString ,
788+ currentContent : newString ,
789+ } )
790+ }
791+ }
792+ }
793+ }
794+
795+ // Calculate stats for this sub-chat and add to workspace total
796+ let subChatAdditions = 0
797+ let subChatDeletions = 0
798+ let subChatFileCount = 0
799+
800+ for ( const [ , state ] of fileStates ) {
801+ const original = state . originalContent || ""
802+ if ( original === state . currentContent ) continue
803+
804+ const oldLines = original ? original . split ( "\n" ) . length : 0
805+ const newLines = state . currentContent
806+ ? state . currentContent . split ( "\n" ) . length
807+ : 0
808+
809+ if ( ! original ) {
810+ // New file
811+ subChatAdditions += newLines
812+ } else {
813+ subChatAdditions += newLines
814+ subChatDeletions += oldLines
815+ }
816+ subChatFileCount += 1
817+ }
818+
819+ // Add to workspace total
820+ const existing = statsMap . get ( row . chatId ) || {
821+ additions : 0 ,
822+ deletions : 0 ,
823+ fileCount : 0 ,
824+ }
825+ existing . additions += subChatAdditions
826+ existing . deletions += subChatDeletions
827+ existing . fileCount += subChatFileCount
828+ statsMap . set ( row . chatId , existing )
829+ } catch {
830+ // Skip invalid JSON
831+ }
832+ }
833+
834+ // Convert to array for easier consumption
835+ return Array . from ( statsMap . entries ( ) ) . map ( ( [ chatId , stats ] ) => ( {
836+ chatId,
837+ ...stats ,
838+ } ) )
839+ } ) ,
840+
841+ /**
842+ * Get sub-chats with pending plan approvals
843+ * Parses messages to find ExitPlanMode tool calls without subsequent "Implement plan" user message
844+ * Logic must match active-chat.tsx hasUnapprovedPlan
845+ * If openSubChatIds provided, only check those sub-chats
846+ */
847+ getPendingPlanApprovals : publicProcedure
848+ . input ( z . object ( { openSubChatIds : z . array ( z . string ( ) ) . optional ( ) } ) . optional ( ) )
849+ . query ( ( { input } ) => {
850+ const db = getDatabase ( )
851+ const openSubChatIdsSet = input ?. openSubChatIds ? new Set ( input . openSubChatIds ) : null
852+
853+ // Get all non-archived chats with their sub-chats
854+ const allSubChats = db
855+ . select ( {
856+ chatId : chats . id ,
857+ subChatId : subChats . id ,
858+ messages : subChats . messages ,
859+ } )
860+ . from ( chats )
861+ . leftJoin ( subChats , eq ( subChats . chatId , chats . id ) )
862+ . where ( isNull ( chats . archivedAt ) )
863+ . all ( )
864+ // Filter by open sub-chats if provided
865+ . filter ( row => ! openSubChatIdsSet || ! row . subChatId || openSubChatIdsSet . has ( row . subChatId ) )
866+
867+ const pendingApprovals : Array < { subChatId : string ; chatId : string } > = [ ]
868+
869+ for ( const row of allSubChats ) {
870+ if ( ! row . messages || ! row . subChatId || ! row . chatId ) continue
871+
872+ try {
873+ const messages = JSON . parse ( row . messages ) as Array < {
874+ role : string
875+ content ?: string
876+ parts ?: Array < {
877+ type : string
878+ text ?: string
879+ } >
880+ } >
881+
882+ // Traverse messages from end to find unapproved ExitPlanMode
883+ // Logic matches active-chat.tsx hasUnapprovedPlan
884+ let hasUnapprovedPlan = false
885+
886+ for ( let i = messages . length - 1 ; i >= 0 ; i -- ) {
887+ const msg = messages [ i ]
888+ if ( ! msg ) continue
889+
890+ // If user message says "Implement plan" (exact match), plan is already approved
891+ if ( msg . role === "user" ) {
892+ const textPart = msg . parts ?. find ( ( p ) => p . type === "text" )
893+ const text = textPart ?. text || ""
894+ if ( text . trim ( ) . toLowerCase ( ) === "implement plan" ) {
895+ break // Plan was approved, stop searching
896+ }
897+ }
898+
899+ // If assistant message with ExitPlanMode, we found an unapproved plan
900+ if ( msg . role === "assistant" && msg . parts ) {
901+ const exitPlanPart = msg . parts . find ( ( p ) => p . type === "tool-ExitPlanMode" )
902+ if ( exitPlanPart ) {
903+ hasUnapprovedPlan = true
904+ break
905+ }
906+ }
907+ }
908+
909+ if ( hasUnapprovedPlan ) {
910+ pendingApprovals . push ( {
911+ subChatId : row . subChatId ,
912+ chatId : row . chatId ,
913+ } )
914+ }
915+ } catch {
916+ // Skip invalid JSON
917+ }
918+ }
919+
920+ return pendingApprovals
921+ } ) ,
710922} )
0 commit comments