@@ -433,6 +433,65 @@ function expandedRecordView(view: LalNodeView): LalRecordView | null {
433433 return view .recordViews .find ((r ) => r .recIdx === e .recIdx ) ?? null ;
434434}
435435
436+ // ── Search + display limit ────────────────────────────────────────
437+
438+ /** Free-text filter on log content. A record matches when ANY of its
439+ * samples carry text containing the substring (case-insensitive):
440+ * - input LogData body.text
441+ * - output LogBuilder.content
442+ * - any LogBuilder tag key/value
443+ * Empty query matches everything. */
444+ const searchQuery = ref <string >(' ' );
445+ /** UI-side cap so a busy capture doesn't render hundreds of columns
446+ * at once; operators raise this when they want the full set. */
447+ const displayLimit = ref <number >(20 );
448+
449+ function recordMatches(rec : SessionRecord , q : string ): boolean {
450+ if (q === ' ' ) return true ;
451+ const needle = q .toLowerCase ();
452+ for (const sample of rec .samples ?? []) {
453+ if (! isLalSamplePayload (sample .payload )) continue ;
454+ const p = sample .payload ;
455+ if (p .input ?.type === ' LogData' ) {
456+ const body = (p .input as LalLogDataInput ).body ;
457+ const text = body ?.text ;
458+ if (typeof text === ' string' && text .toLowerCase ().includes (needle )) return true ;
459+ }
460+ if (p .output ?.type === ' LogBuilder' ) {
461+ const out = p .output as LalLogBuilderOutput ;
462+ if (typeof out .content === ' string' && out .content .toLowerCase ().includes (needle )) {
463+ return true ;
464+ }
465+ for (const t of out .tags ?? []) {
466+ if (
467+ t .key .toLowerCase ().includes (needle ) ||
468+ t .value .toLowerCase ().includes (needle )
469+ ) {
470+ return true ;
471+ }
472+ }
473+ }
474+ }
475+ return false ;
476+ }
477+
478+ /** Per-node post-filter view. Keeps the cells map intact (lookups
479+ * are by recIdx and the surviving recordViews carry their original
480+ * ids), trims `recordViews` to matching + capped subset. */
481+ function displayedRecords(view : LalNodeView ): LalRecordView [] {
482+ const q = searchQuery .value .trim ();
483+ const matched = q === ' '
484+ ? view .recordViews
485+ : view .recordViews .filter ((rv ) => recordMatches (rv .rec , q ));
486+ return matched .slice (0 , displayLimit .value );
487+ }
488+
489+ function matchedRecordCount(view : LalNodeView ): number {
490+ const q = searchQuery .value .trim ();
491+ if (q === ' ' ) return view .recordViews .length ;
492+ return view .recordViews .filter ((rv ) => recordMatches (rv .rec , q )).length ;
493+ }
494+
436495const sourceDsl = computed <string | null >(() => {
437496 const sel = selectedCell .value ;
438497 if (sel ) return sel .rec .dsl ?? null ;
@@ -578,6 +637,30 @@ void TAG_STATUS_TONE;
578637 </div >
579638 </template >
580639
640+ <template #subhead >
641+ <div class =" lal__subhead" >
642+ <label class =" lal__searchwrap" >
643+ <span class =" lal__searchlbl" >search</span >
644+ <input
645+ v-model =" searchQuery"
646+ type =" search"
647+ class =" lal__searchinput"
648+ placeholder =" filter records by log content / tag…"
649+ />
650+ </label >
651+ <label class =" lal__limitwrap" >
652+ <span class =" lal__searchlbl" >show first</span >
653+ <input
654+ v-model.number =" displayLimit"
655+ type =" number"
656+ min =" 1"
657+ max =" 100"
658+ class =" lal__limitinput"
659+ />
660+ </label >
661+ </div >
662+ </template >
663+
581664 <template #idle-hint >
582665 pick a LAL rule and hit start. each captured log becomes one
583666 column in the matrix; rows walk the per-record blocks
@@ -692,13 +775,34 @@ void TAG_STATUS_TONE;
692775 <!-- Default: per-record × per-block matrix. -->
693776 <div v-else class =" lal__matrixwrap" >
694777 <div
778+ v-if =" displayedRecords(node).length === 0"
779+ class =" lal__nomatch"
780+ >
781+ <template v-if =" searchQuery .trim () === ' ' " >no records on this node</template >
782+ <template v-else >
783+ no records match
784+ <code >{{ searchQuery }}</code >
785+ ({{ node.recordViews.length }} captured total)
786+ </template >
787+ </div >
788+ <div
789+ v-else
695790 class =" lal__matrix"
696- :style =" `grid-template-columns: 180px repeat(${node.recordViews .length}, minmax(200px, 1fr));`"
791+ :style =" `grid-template-columns: 180px repeat(${displayedRecords( node) .length}, minmax(200px, 1fr));`"
697792 >
698793 <!-- header row: blank label cell + record headers -->
699- <div class =" lal__hdrlbl" >block ▾ / record →</div >
794+ <div class =" lal__hdrlbl" >
795+ block ▾ / record →
796+ <div class =" lal__hdrlblct" >
797+ showing {{ displayedRecords(node).length }}
798+ of {{ matchedRecordCount(node) }}
799+ <span v-if =" matchedRecordCount(node) !== node.recordViews.length" >
800+ · {{ node.recordViews.length }} captured
801+ </span >
802+ </div >
803+ </div >
700804 <div
701- v-for =" rv in node.recordViews "
805+ v-for =" rv in displayedRecords( node) "
702806 :key =" `${nodeKey(node)}-h-${rv.recIdx}`"
703807 class =" lal__hdrec"
704808 :class =" {
@@ -722,14 +826,12 @@ void TAG_STATUS_TONE;
722826 <div class =" lal__steplbl" >
723827 <div class =" lal__stepkind" >{{ step.label }}</div >
724828 <div class =" lal__stepct" >
725- {{
726- Object.keys(Object.fromEntries([...node.cells.get(step.key)?.entries() ?? []])).length
727- }}
728- / {{ node.recordViews.length }} records
829+ {{ displayedRecords(node).filter((rv) => cellAt(node, step, rv.recIdx) !== undefined).length }}
830+ / {{ displayedRecords(node).length }} records
729831 </div >
730832 </div >
731833 <div
732- v-for =" rv in node.recordViews "
834+ v-for =" rv in displayedRecords( node) "
733835 :key =" `${step.key}-${rv.recIdx}`"
734836 class =" lal__cell"
735837 :class =" {
@@ -901,6 +1003,73 @@ void TAG_STATUS_TONE;
9011003 text-transform : uppercase ;
9021004}
9031005
1006+ .lal__hdrlblct {
1007+ margin-top : 4px ;
1008+ font-family : var (--rr-font-mono );
1009+ font-size : 10px ;
1010+ letter-spacing : 0 ;
1011+ text-transform : none ;
1012+ color : var (--rr-ink2 );
1013+ }
1014+
1015+ .lal__subhead {
1016+ display : flex ;
1017+ align-items : center ;
1018+ gap : 14px ;
1019+ flex-wrap : wrap ;
1020+ padding : 4px 0 ;
1021+ }
1022+
1023+ .lal__searchwrap ,
1024+ .lal__limitwrap {
1025+ display : flex ;
1026+ align-items : center ;
1027+ gap : 6px ;
1028+ font-family : var (--rr-font-mono );
1029+ }
1030+
1031+ .lal__searchlbl {
1032+ font-size : 11px ;
1033+ letter-spacing : 0.6px ;
1034+ text-transform : uppercase ;
1035+ color : var (--rr-dim );
1036+ }
1037+
1038+ .lal__searchinput ,
1039+ .lal__limitinput {
1040+ background : var (--rr-bg2 );
1041+ color : var (--rr-ink );
1042+ border : 1px solid var (--rr-border );
1043+ padding : 3px 8px ;
1044+ font-family : var (--rr-font-mono );
1045+ font-size : 13px ;
1046+ }
1047+
1048+ .lal__searchinput {
1049+ min-width : 320px ;
1050+ }
1051+
1052+ .lal__limitinput {
1053+ width : 60px ;
1054+ }
1055+
1056+ .lal__nomatch {
1057+ padding : 24px 18px ;
1058+ font-family : var (--rr-font-mono );
1059+ font-size : 13px ;
1060+ color : var (--rr-dim );
1061+ text-align : center ;
1062+ font-style : italic ;
1063+ }
1064+
1065+ .lal__nomatch code {
1066+ font-family : var (--rr-font-mono );
1067+ background : var (--rr-bg2 );
1068+ padding : 1px 5px ;
1069+ font-style : normal ;
1070+ color : var (--rr-ink );
1071+ }
1072+
9041073.lal__hdrec {
9051074 position : sticky ;
9061075 top : 0 ;
0 commit comments