@@ -11,8 +11,22 @@ import {
1111 type SessionInventoryItem ,
1212} from './local-session-state.js' ;
1313import { actions , divMd , divPlain , shell , type ButtonSpec } from '../card/templates.js' ;
14+ import { log } from '../core/logger.js' ;
1415import { readLastCodexResponse } from '../session/codex-transcript.js' ;
1516
17+ /**
18+ * Which one-click ownership transfer a Session currently offers. The list card
19+ * groups Sessions by this so the default view only shows rows that can act.
20+ */
21+ type SessionCategory = 'handoff' | 'use' | 'handback' | 'none' ;
22+
23+ const SESSION_FILTERS = [ 'handoff' , 'use' , 'handback' , 'all' ] as const ;
24+
25+ type SessionFilter = ( typeof SESSION_FILTERS ) [ number ] ;
26+
27+ /** How many Sessions the category tabs are allowed to draw from. */
28+ const SESSION_FILTER_POOL = 20 ;
29+
1630function clean (
1731 value : unknown ,
1832) : string {
@@ -384,6 +398,68 @@ function inventoryForContext(
384398 } ;
385399}
386400
401+ /*
402+ * Mirror of the decision tree in sessionActionButtons(). Kept as a separate
403+ * pure function so the category tabs and the per-row buttons cannot disagree:
404+ * a Session listed under "Handoff" is exactly one that renders a Handoff
405+ * button. Any change to one must be made in the other.
406+ */
407+ function sessionCategory (
408+ item : SessionInventoryItem ,
409+ bindings : LarkBinding [ ] ,
410+ ) : SessionCategory {
411+ const linked = bindingsFor ( item , bindings ) ;
412+ const current = linked . some ( ( binding ) => binding . current ) ;
413+
414+ // Conflicting Windows + Lark ownership: inspect before transferring.
415+ if ( item . handoff . windowsActive && linked . length > 0 ) {
416+ return 'none' ;
417+ }
418+
419+ if ( current ) {
420+ return 'handback' ;
421+ }
422+
423+ // Bound to another Lark scope — not movable from here.
424+ if ( linked . length > 0 ) {
425+ return 'none' ;
426+ }
427+
428+ if ( item . handoff . windowsActive ) {
429+ return item . handoff . code === 'busy' ? 'none' : 'handoff' ;
430+ }
431+
432+ return 'use' ;
433+ }
434+
435+ function filterTabButtons (
436+ counts : Record < SessionFilter , number > ,
437+ active : SessionFilter ,
438+ ) : ButtonSpec [ ] {
439+ const labels : Record < SessionFilter , string > = {
440+ handoff : '↪ Handoff' ,
441+ use : '▶ Use' ,
442+ handback : '↩ Hand Back' ,
443+ all : '🗂 All' ,
444+ } ;
445+
446+ const hints : Record < SessionFilter , string > = {
447+ handoff : 'Show only Sessions that can be handed off from Windows to this Lark scope.' ,
448+ use : 'Show only Detached Sessions that can be bound to this Lark scope.' ,
449+ handback : 'Show only Sessions bound to this Lark scope that can be handed back to Windows.' ,
450+ all : 'Show every Session, including ones with no available transfer action.' ,
451+ } ;
452+
453+ return SESSION_FILTERS . map ( ( filter ) => ( {
454+ // The active tab is marked in the label because Lark gives no
455+ // selected-state styling for buttons inside an action row.
456+ text : `${ filter === active ? '● ' : '' } ${ labels [ filter ] } (${ counts [ filter ] } )` ,
457+ value : { cmd : 'session.list' , arg : filter } ,
458+ style : filter === active ? 'primary' : 'default' ,
459+ hoverTips : hints [ filter ] ,
460+ } ) ) ;
461+ }
462+
387463function sessionActionButtons (
388464 ctx : any ,
389465 item : SessionInventoryItem ,
@@ -472,55 +548,88 @@ export async function handleLocalSessions(
472548 }
473549
474550 const input = args . trim ( ) ;
475- const showAll = input . toLowerCase ( ) === 'all' ;
476- const keyword = showAll ? '' : input . toLowerCase ( ) ;
551+ const lowered = input . toLowerCase ( ) ;
552+ const asFilter = SESSION_FILTERS . find ( ( filter ) => filter === lowered ) ;
553+ // A bare filter word selects a category tab; anything else is a keyword search.
554+ const keyword = asFilter ? '' : lowered ;
477555 const { items, bindings } = inventoryForContext ( ctx ) ;
478556
479- let candidates = items ;
557+ let candidates : SessionInventoryItem [ ] ;
558+ let counts : Record < SessionFilter , number > | undefined ;
559+ let activeFilter : SessionFilter | undefined ;
480560
481561 if ( keyword ) {
482- candidates = items . filter (
483- ( item ) =>
484- item . sessionId . toLowerCase ( ) . includes ( keyword ) ||
485- item . threadName ?. toLowerCase ( ) . includes ( keyword ) ||
486- item . projectName ?. toLowerCase ( ) . includes ( keyword ) ,
487- ) ;
488- }
489- else if ( ! showAll ) {
490- // Default view: all currently-owned sessions + a small recent detached history.
491- const important = items . filter (
492- ( item ) => item . handoff . windowsActive || bindingsFor ( item , bindings ) . length > 0 ,
493- ) ;
494- const detached = items
562+ candidates = items
495563 . filter (
496- ( item ) => ! item . handoff . windowsActive && bindingsFor ( item , bindings ) . length === 0 ,
564+ ( item ) =>
565+ item . sessionId . toLowerCase ( ) . includes ( keyword ) ||
566+ item . threadName ?. toLowerCase ( ) . includes ( keyword ) ||
567+ item . projectName ?. toLowerCase ( ) . includes ( keyword ) ,
497568 )
498- . slice ( 0 , 10 ) ;
499-
500- candidates = [ ...important , ...detached ] ;
501- const seen = new Set < string > ( ) ;
502- candidates = candidates . filter ( ( item ) => {
503- if ( seen . has ( item . sessionId ) ) return false ;
504- seen . add ( item . sessionId ) ;
505- return true ;
506- } ) ;
569+ . slice ( 0 , 30 ) ;
570+ } else {
571+ /*
572+ * Category tabs replace the old "recent history" heuristic. Only the most
573+ * recent SESSION_FILTER_POOL Sessions participate, so the counts on the
574+ * tabs always describe exactly what the tabs can show.
575+ */
576+ const pool = items . slice ( 0 , SESSION_FILTER_POOL ) ;
577+ const byCategory = new Map < SessionCategory , SessionInventoryItem [ ] > ( ) ;
578+ for ( const item of pool ) {
579+ const category = sessionCategory ( item , bindings ) ;
580+ const bucket = byCategory . get ( category ) ;
581+ if ( bucket ) bucket . push ( item ) ;
582+ else byCategory . set ( category , [ item ] ) ;
583+ }
584+
585+ counts = {
586+ handoff : byCategory . get ( 'handoff' ) ?. length ?? 0 ,
587+ use : byCategory . get ( 'use' ) ?. length ?? 0 ,
588+ handback : byCategory . get ( 'handback' ) ?. length ?? 0 ,
589+ all : pool . length ,
590+ } ;
591+
592+ /*
593+ * Default to Handoff because that is the state needing a decision. When it
594+ * is empty, fall through to the next non-empty category rather than opening
595+ * on a blank card.
596+ */
597+ activeFilter =
598+ asFilter ??
599+ ( [ 'handoff' , 'handback' , 'use' ] as const ) . find ( ( filter ) => counts ! [ filter ] > 0 ) ??
600+ 'all' ;
601+
602+ candidates =
603+ activeFilter === 'all' ? pool : ( byCategory . get ( activeFilter ) ?? [ ] ) ;
507604 }
508605
509- const limit = showAll ? 30 : 20 ;
510- candidates = candidates . slice ( 0 , limit ) . map ( hydrateInventoryItem ) ;
606+ candidates = candidates . map ( hydrateInventoryItem ) ;
511607
512- if ( candidates . length === 0 ) {
513- await reply (
514- ctx ,
515- keyword
516- ? `No Codex Session matched ** ${ clean ( input ) } **.`
517- : 'No Codex Sessions were found.' ,
518- ) ;
608+ if ( candidates . length === 0 && keyword ) {
609+ await reply ( ctx , `No Codex Session matched ** ${ clean ( input ) } **.` ) ;
610+ return ;
611+ }
612+
613+ if ( counts ?. all === 0 ) {
614+ await reply ( ctx , 'No Codex Sessions were found.' ) ;
519615 return ;
520616 }
521617
522618 const elements : object [ ] = [ ] ;
523619
620+ if ( counts && activeFilter ) {
621+ elements . push ( actions ( filterTabButtons ( counts , activeFilter ) ) ) ;
622+ elements . push ( { tag : 'hr' } ) ;
623+ }
624+
625+ if ( candidates . length === 0 ) {
626+ elements . push (
627+ divMd (
628+ `No Session in the most recent ${ SESSION_FILTER_POOL } currently offers this action. Pick another category above.` ,
629+ ) ,
630+ ) ;
631+ }
632+
524633 for ( const [ idx , item ] of candidates . entries ( ) ) {
525634 const linked = bindingsFor ( item , bindings ) ;
526635 const current = linked . some ( ( binding ) => binding . current ) ;
@@ -566,14 +675,56 @@ export async function handleLocalSessions(
566675 '• **Handoff to this Lark** — Windows → current Lark scope' ,
567676 '• **Hand Back to Windows** — current Lark scope → Detached / Windows-ready' ,
568677 '• **Last Response** — read the latest completed user-visible Codex response' ,
569- ...( ! showAll && ! keyword && items . length > candidates . length
570- ? [ '' , 'Use **/session list all** to show more Session history.' ]
678+ ...( counts && items . length > counts . all
679+ ? [
680+ '' ,
681+ `Showing the most recent ${ SESSION_FILTER_POOL } of ${ items . length } Sessions. Use **/session list <keyword>** to find an older one.` ,
682+ ]
571683 : [ ] ) ,
572684 ] . join ( '\n' ) ,
573685 ) ,
574686 ) ;
575687
576- await replyCard ( ctx , shell ( '🗂️ All Codex Sessions' , elements ) ) ;
688+ const card = shell ( '🗂️ All Codex Sessions' , elements ) ;
689+
690+ /*
691+ * A tab click re-runs this handler with ctx.msg.messageId set to the card's
692+ * own message (see makeFakeMsg in card/dispatcher.ts). Recall that card and
693+ * post the replacement, which is the same pattern the /account and /config
694+ * card flows use.
695+ *
696+ * Two rejected alternatives, both of which fail silently:
697+ * - channel.updateCard() → im.v1.message.patch. Feishu answers HTTP 200
698+ * with a non-zero body code and the SDK discards the response, so a
699+ * refused patch throws nothing and renders nothing.
700+ * - sendManagedCard() → cardkit.card.create, which rejects the v1 schema
701+ * that shell() builds ("returned no card_id").
702+ *
703+ * Cost of recall-and-resend: the card moves to the bottom of the chat on each
704+ * tab switch instead of updating where it sits.
705+ */
706+ if ( ctx . fromCardAction && ctx . msg . messageId ) {
707+ /*
708+ * Send first, recall second. The replacement is sent as a reply to the old
709+ * card because that is the only way to land in the right topic thread
710+ * (SendOptions has no threadId — only replyTo/replyInThread), and recalling
711+ * a parent message in Feishu does not remove replies already made to it.
712+ */
713+ const stale = ctx . msg . messageId ;
714+ await replyCard ( ctx , card ) ;
715+
716+ try {
717+ await ctx . channel . recallMessage ( stale ) ;
718+ } catch ( err ) {
719+ // Leaves the old card above the new one — visible, not silent.
720+ log . warn ( 'command' , 'session-list-recall-failed' , {
721+ err : err instanceof Error ? err . message : String ( err ) ,
722+ } ) ;
723+ }
724+ return ;
725+ }
726+
727+ await replyCard ( ctx , card ) ;
577728}
578729
579730function formatResponseTime (
0 commit comments