@@ -33,6 +33,8 @@ function sessionRow(
3333 hasActiveRun ?: boolean ;
3434 status ?: string ;
3535 spawnedBy ?: string ;
36+ execNode ?: string ;
37+ worktree ?: { branch ?: string ; repoRoot ?: string } ;
3638 } = { } ,
3739) {
3840 return {
@@ -969,6 +971,119 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
969971 }
970972 } ) ;
971973
974+ it ( "keeps raw ids out of work rows and survives rows growing subtitles in place" , async ( ) => {
975+ const baseTime = Date . parse ( "2026-07-01T16:00:00.000Z" ) ;
976+ const nodeHash = "11c38726acc6fac280357576c87acc6fac280357" ;
977+ const rows = ( withWork : boolean ) => {
978+ const ts = baseTime + ( withWork ? 5_000 : 0 ) ;
979+ return [
980+ sessionRow ( "agent:main:main" , "Main" , ts ) ,
981+ sessionRow (
982+ "agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6d" ,
983+ "" ,
984+ ts - 60_000 ,
985+ withWork ? { execNode : nodeHash } : { } ,
986+ ) ,
987+ sessionRow (
988+ "agent:main:dashboard:0f9d5c1e-6d0f-4c9a-9d84-1c2f3a4b5c6e" ,
989+ "" ,
990+ ts - 120_000 ,
991+ withWork
992+ ? {
993+ execNode : nodeHash ,
994+ worktree : { branch : "openclaw/wt-1" , repoRoot : "/Users/dev/Projects/clawdbot" } ,
995+ }
996+ : { } ,
997+ ) ,
998+ sessionRow ( "agent:main:node-mcp-debug-4de003fbff138fcb9239c9378b2e" , "" , ts - 180_000 ) ,
999+ ] ;
1000+ } ;
1001+ const context = await browser . newContext ( {
1002+ locale : "en-US" ,
1003+ serviceWorkers : "block" ,
1004+ viewport : { height : 900 , width : 1280 } ,
1005+ } ) ;
1006+ const page = await context . newPage ( ) ;
1007+ const gateway = await installMockGateway ( page , {
1008+ methodResponses : {
1009+ "sessions.list" : sessionsListResponse ( rows ( false ) ) ,
1010+ } ,
1011+ // Defer every list request; the test resolves them per phase so rows
1012+ // first paint single-line and only then grow a subtitle line in place.
1013+ deferredMethods : Array . from ( { length : 12 } , ( ) => "sessions.list" ) ,
1014+ sessionKey : "agent:main:main" ,
1015+ } ) ;
1016+ const resolveNextList = async ( withWork : boolean ) => {
1017+ try {
1018+ await gateway . resolveDeferred ( "sessions.list" , sessionsListResponse ( rows ( withWork ) ) ) ;
1019+ } catch {
1020+ // No list request queued yet; the poll below retries.
1021+ }
1022+ } ;
1023+
1024+ try {
1025+ await page . goto ( `${ server . baseUrl } chat` ) ;
1026+ await expect
1027+ . poll (
1028+ async ( ) => {
1029+ await resolveNextList ( false ) ;
1030+ return page . locator ( ".sidebar-recent-session" ) . count ( ) ;
1031+ } ,
1032+ { timeout : 15_000 } ,
1033+ )
1034+ . toBeGreaterThan ( 0 ) ;
1035+ // Rows grow a second subtitle line only after first layout: the WebKit
1036+ // overlap regression needs in-place growth, not a static two-line list.
1037+ await gateway . emitGatewayEvent ( "sessions.changed" , { } ) ;
1038+ await expect
1039+ . poll (
1040+ async ( ) => {
1041+ await resolveNextList ( true ) ;
1042+ return page . locator ( ".sidebar-recent-session__subtitle" ) . count ( ) ;
1043+ } ,
1044+ { timeout : 15_000 } ,
1045+ )
1046+ . toBeGreaterThan ( 0 ) ;
1047+
1048+ // Names and subtitles never show raw node ids or raw agent keys.
1049+ const names = await trimmedTextContents ( page . locator ( ".sidebar-recent-session__name" ) ) ;
1050+ expect ( names ) . toContain ( "New session" ) ;
1051+ expect ( names ) . toContain ( "clawdbot ⎇ wt-1 · …0357" ) ;
1052+ expect ( names ) . toContain ( "node-mcp-debug-…8b2e" ) ;
1053+ const subtitles = await trimmedTextContents (
1054+ page . locator ( ".sidebar-recent-session__subtitle" ) ,
1055+ ) ;
1056+ expect ( subtitles ) . toContain ( "…0357" ) ;
1057+ for ( const text of [ ...names , ...subtitles ] ) {
1058+ expect ( text ) . not . toContain ( nodeHash ) ;
1059+ expect ( text ) . not . toContain ( "agent:main:" ) ;
1060+ }
1061+
1062+ // Sections must lay out below the rows above them, not paint over them.
1063+ const overlaps = await page . evaluate ( ( ) => {
1064+ const rects = [
1065+ ...document . querySelectorAll ( ".sidebar-recent-session, .sidebar-recent-sessions__head" ) ,
1066+ ]
1067+ . map ( ( element ) => {
1068+ const rect = element . getBoundingClientRect ( ) ;
1069+ return { top : rect . top , bottom : rect . bottom } ;
1070+ } )
1071+ . filter ( ( rect ) => rect . bottom > rect . top )
1072+ . toSorted ( ( a , b ) => a . top - b . top ) ;
1073+ let bad = 0 ;
1074+ for ( let index = 1 ; index < rects . length ; index += 1 ) {
1075+ if ( rects [ index ] . top < rects [ index - 1 ] . bottom - 2 ) {
1076+ bad += 1 ;
1077+ }
1078+ }
1079+ return bad ;
1080+ } ) ;
1081+ expect ( overlaps ) . toBe ( 0 ) ;
1082+ } finally {
1083+ await context . close ( ) ;
1084+ }
1085+ } ) ;
1086+
9721087 it ( "keeps sidebar session controls reachable on touch pointers" , async ( ) => {
9731088 const context = await browser . newContext ( {
9741089 hasTouch : true ,
0 commit comments