@@ -8,6 +8,7 @@ class Dashboard {
88 this . isVisible = false ;
99 this . quickLinks = null ;
1010 this . _escHandler = null ;
11+ this . _projectLaunchInFlight = false ;
1112 }
1213
1314 async show ( ) {
@@ -467,7 +468,7 @@ class Dashboard {
467468 . replace ( / < / g, '<' )
468469 . replace ( / > / g, '>' ) ;
469470
470- const renderAdvice = async ( { force = false } = { } ) => {
471+ const renderAdvice = async ( { force = false } = { } ) => {
471472 if ( ! adviceEl ) return ;
472473 adviceEl . textContent = 'Loading…' ;
473474
@@ -526,13 +527,22 @@ class Dashboard {
526527 } ) ;
527528 } ;
528529
529- try {
530- const [ statusRes , telemetryRes , projectsRes , readinessRes ] = await Promise . all ( [
531- showStatus ? fetch ( '/api/process/status?mode=mine' ) . catch ( ( ) => null ) : Promise . resolve ( null ) ,
532- showTelemetry ? fetch ( '/api/process/telemetry' ) . catch ( ( ) => null ) : Promise . resolve ( null ) ,
533- showProjects ? fetch ( '/api/process/projects?mode=mine' ) . catch ( ( ) => null ) : Promise . resolve ( null ) ,
534- showReadiness ? fetch ( '/api/process/readiness/templates' ) . catch ( ( ) => null ) : Promise . resolve ( null )
535- ] ) ;
530+ try {
531+ const projectsBoardPromise = ( showProjects && this . orchestrator ?. getProjectsBoard )
532+ ? this . orchestrator . getProjectsBoard ( { force : false } ) . catch ( ( ) => null )
533+ : Promise . resolve ( null ) ;
534+ const scannedReposPromise = ( showProjects && this . orchestrator ?. getScannedRepos )
535+ ? this . orchestrator . getScannedRepos ( { force : false } ) . catch ( ( ) => [ ] )
536+ : Promise . resolve ( [ ] ) ;
537+
538+ const [ statusRes , telemetryRes , projectsRes , readinessRes , projectsBoardData , scannedRepos ] = await Promise . all ( [
539+ showStatus ? fetch ( '/api/process/status?mode=mine' ) . catch ( ( ) => null ) : Promise . resolve ( null ) ,
540+ showTelemetry ? fetch ( '/api/process/telemetry' ) . catch ( ( ) => null ) : Promise . resolve ( null ) ,
541+ showProjects ? fetch ( '/api/process/projects?mode=mine' ) . catch ( ( ) => null ) : Promise . resolve ( null ) ,
542+ showReadiness ? fetch ( '/api/process/readiness/templates' ) . catch ( ( ) => null ) : Promise . resolve ( null ) ,
543+ projectsBoardPromise ,
544+ scannedReposPromise
545+ ] ) ;
536546
537547 if ( showStatus && statusEl ) {
538548 const data = statusRes ? await statusRes . json ( ) . catch ( ( ) => ( { } ) ) : { } ;
@@ -571,68 +581,211 @@ class Dashboard {
571581 }
572582 }
573583
574- if ( showProjects && projectsEl ) {
575- const data = projectsRes ? await projectsRes . json ( ) . catch ( ( ) => ( { } ) ) : { } ;
576- if ( projectsRes && projectsRes . ok ) {
577- const totals = data ?. totals || { } ;
578- const repos = Array . isArray ( data ?. repos ) ? data . repos : [ ] ;
579- const top = repos . slice ( 0 , 6 ) ;
580-
581- const pickWorstRisk = ( counts ) => {
582- const c = counts && typeof counts === 'object' ? counts : { } ;
583- if ( Number ( c . critical || 0 ) > 0 ) return 'critical' ;
584- if ( Number ( c . high || 0 ) > 0 ) return 'high' ;
585- if ( Number ( c . medium || 0 ) > 0 ) return 'medium' ;
586- if ( Number ( c . low || 0 ) > 0 ) return 'low' ;
587- return '' ;
588- } ;
584+ if ( showProjects && projectsEl ) {
585+ const data = projectsRes ? await projectsRes . json ( ) . catch ( ( ) => ( { } ) ) : { } ;
586+ const projectsBoard = projectsBoardData ?. board && typeof projectsBoardData . board === 'object' ? projectsBoardData . board : null ;
587+ const scanned = Array . isArray ( scannedRepos ) ? scannedRepos : [ ] ;
589588
590- const riskChip = ( risk ) => {
591- const r = String ( risk || '' ) . trim ( ) . toLowerCase ( ) ;
592- if ( ! r ) return '' ;
593- const cls = ( r === 'critical' || r === 'high' ) ? 'level-warn' : '' ;
594- return `<span class="process-chip ${ cls } ">${ escapeHtml ( r ) } </span>` ;
595- } ;
589+ const normalizeKey = ( value ) => ( this . orchestrator ?. normalizeProjectsBoardProjectKey ?. ( value ) ?? String ( value || '' ) . trim ( ) . replace ( / \\ / g, '/' ) ) ;
596590
597- projectsEl . innerHTML = `
598- <div>Repos <strong>${ Number ( totals ?. repos ?? top . length ?? 0 ) } </strong> • Open PRs <strong>${ Number ( totals ?. prsOpen ?? 0 ) } </strong></div>
599- <div>Unreviewed <strong>${ Number ( totals ?. prsUnreviewed ?? 0 ) } </strong> • Needs fix <strong>${ Number ( totals ?. prsNeedsFix ?? 0 ) } </strong></div>
600- <div style="margin-top:8px; display:flex; flex-direction:column; gap:6px;">
601- ${ top . length ? top . map ( ( r ) => {
602- const repo = String ( r ?. repo || '' ) . trim ( ) ;
603- const open = Number ( r ?. prsOpen ?? 0 ) ;
604- const unrev = Number ( r ?. prsUnreviewed ?? 0 ) ;
605- const avgReview = r ?. telemetry ?. avgReviewSeconds ? `${ Math . round ( Number ( r . telemetry . avgReviewSeconds ) ) } s` : '—' ;
606- const worstRisk = pickWorstRisk ( r ?. riskCounts ) ;
607- return `
608- <button class="btn-secondary" type="button" data-open-repo="${ escapeHtml ( repo ) } " title="Open PRs filtered to ${ escapeHtml ( repo ) } " style="width:100%; display:flex; justify-content:space-between; align-items:center; gap:10px;">
609- <span style="min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${ escapeHtml ( repo ) } (${ open } open, ${ unrev } unrev)</span>
610- <span style="display:flex; align-items:center; gap:8px; flex-shrink:0;">
611- ${ worstRisk ? riskChip ( worstRisk ) : '' }
612- <span style="opacity:0.8;">${ escapeHtml ( avgReview ) } </span>
613- </span>
614- </button>
615- ` ;
616- } ) . join ( '' ) : `<div style="opacity:0.8;">No PRs found.</div>` }
617- </div>
618- ` ;
591+ const boardHtml = ( ( ) => {
592+ if ( ! projectsBoard || scanned . length === 0 ) return '' ;
619593
620- projectsEl . querySelectorAll ( '[data-open-repo]' ) . forEach ( ( btn ) => {
621- btn . addEventListener ( 'click' , ( ) => {
622- const repo = btn . getAttribute ( 'data-open-repo' ) || '' ;
623- if ( ! repo ) return ;
624- try {
625- localStorage . setItem ( 'prs-panel-repo' , repo ) ;
626- } catch { }
627- try {
628- this . orchestrator ?. showPRsPanel ?. ( ) ;
629- } catch { }
630- } ) ;
631- } ) ;
632- } else {
633- projectsEl . textContent = 'Failed to load.' ;
634- }
635- }
594+ const repoByKey = new Map ( ) ;
595+ for ( const repo of scanned ) {
596+ const key = normalizeKey ( repo ?. relativePath ) ;
597+ if ( ! key ) continue ;
598+ if ( ! repoByKey . has ( key ) ) repoByKey . set ( key , repo ) ;
599+ }
600+ if ( ! repoByKey . size ) return '' ;
601+
602+ const getOrderIndex = ( columnId ) => {
603+ const raw = projectsBoard ?. orderByColumn && typeof projectsBoard . orderByColumn === 'object'
604+ ? projectsBoard . orderByColumn [ columnId ]
605+ : null ;
606+ const order = Array . isArray ( raw ) ? raw : [ ] ;
607+ const index = new Map ( ) ;
608+ order . forEach ( ( k , i ) => {
609+ const key = normalizeKey ( k ) ;
610+ if ( ! key || index . has ( key ) ) return ;
611+ index . set ( key , i ) ;
612+ } ) ;
613+ return index ;
614+ } ;
615+
616+ const collect = ( columnId ) => {
617+ const out = [ ] ;
618+ for ( const [ key , repo ] of repoByKey . entries ( ) ) {
619+ const col = this . orchestrator ?. getProjectsBoardColumnForProjectKey ?. ( key , projectsBoardData ) || 'backlog' ;
620+ if ( col === columnId ) out . push ( { key, repo } ) ;
621+ }
622+ const index = getOrderIndex ( columnId ) ;
623+ out . sort ( ( a , b ) => {
624+ const aRank = index . has ( a . key ) ? index . get ( a . key ) : Number . POSITIVE_INFINITY ;
625+ const bRank = index . has ( b . key ) ? index . get ( b . key ) : Number . POSITIVE_INFINITY ;
626+ if ( aRank !== bRank ) return aRank - bRank ;
627+ return String ( a . repo ?. name || '' ) . localeCompare ( String ( b . repo ?. name || '' ) ) ;
628+ } ) ;
629+ return out ;
630+ } ;
631+
632+ const shipNext = collect ( 'next' ) ;
633+ const active = collect ( 'active' ) ;
634+ const total = shipNext . length + active . length ;
635+ if ( total === 0 ) return '' ;
636+
637+ const tagMap = projectsBoard ?. tagsByProjectKey && typeof projectsBoard . tagsByProjectKey === 'object'
638+ ? projectsBoard . tagsByProjectKey
639+ : { } ;
640+
641+ const renderTile = ( item ) => {
642+ const icon = this . orchestrator ?. getProjectIcon ?. ( item ?. repo ?. type ) || '📁' ;
643+ const name = String ( item ?. repo ?. name || item ?. key || '' ) . trim ( ) ;
644+ const key = normalizeKey ( item ?. key ) ;
645+ const category = String ( item ?. repo ?. category || '' ) . trim ( ) ;
646+ const type = String ( item ?. repo ?. type || '' ) . trim ( ) ;
647+ const subtitle = category ? `${ category } • ${ key } ` : key ;
648+ const isLive = ! ! tagMap [ key ] ?. live ;
649+ return `
650+ <button type="button"
651+ class="dashboard-project-tile ${ isLive ? 'is-live' : '' } "
652+ data-dashboard-start-project="${ escapeHtml ( key ) } "
653+ data-project-type="${ escapeHtml ( type ) } "
654+ title="Start worktree: ${ escapeHtml ( name ) } ">
655+ <span class="dashboard-project-tile-icon">${ escapeHtml ( icon ) } </span>
656+ <span class="dashboard-project-tile-text">
657+ <span class="dashboard-project-tile-name">${ escapeHtml ( name ) } </span>
658+ <span class="dashboard-project-tile-subtitle">${ escapeHtml ( subtitle ) } </span>
659+ </span>
660+ ${ isLive ? `<span class="dashboard-project-tile-live" title="Live">★</span>` : '' }
661+ </button>
662+ ` ;
663+ } ;
664+
665+ const renderGroup = ( label , list ) => {
666+ if ( ! list . length ) return '' ;
667+ return `
668+ <div class="dashboard-project-group">
669+ <div class="dashboard-project-group-title">${ escapeHtml ( label ) } <span class="dashboard-project-group-count">${ list . length } </span></div>
670+ <div class="dashboard-project-grid">
671+ ${ list . map ( renderTile ) . join ( '' ) }
672+ </div>
673+ </div>
674+ ` ;
675+ } ;
676+
677+ return `
678+ <div class="dashboard-projects-board">
679+ ${ renderGroup ( 'Ship Next' , shipNext ) }
680+ ${ renderGroup ( 'Active' , active ) }
681+ </div>
682+ ` ;
683+ } ) ( ) ;
684+
685+ const prSummaryHtml = ( ( ) => {
686+ if ( ! ( projectsRes && projectsRes . ok ) ) {
687+ return `<div style="opacity:0.85;">Failed to load PR summary.</div>` ;
688+ }
689+
690+ const totals = data ?. totals || { } ;
691+ const repos = Array . isArray ( data ?. repos ) ? data . repos : [ ] ;
692+ const top = repos . slice ( 0 , 6 ) ;
693+
694+ const pickWorstRisk = ( counts ) => {
695+ const c = counts && typeof counts === 'object' ? counts : { } ;
696+ if ( Number ( c . critical || 0 ) > 0 ) return 'critical' ;
697+ if ( Number ( c . high || 0 ) > 0 ) return 'high' ;
698+ if ( Number ( c . medium || 0 ) > 0 ) return 'medium' ;
699+ if ( Number ( c . low || 0 ) > 0 ) return 'low' ;
700+ return '' ;
701+ } ;
702+
703+ const riskChip = ( risk ) => {
704+ const r = String ( risk || '' ) . trim ( ) . toLowerCase ( ) ;
705+ if ( ! r ) return '' ;
706+ const cls = ( r === 'critical' || r === 'high' ) ? 'level-warn' : '' ;
707+ return `<span class="process-chip ${ cls } ">${ escapeHtml ( r ) } </span>` ;
708+ } ;
709+
710+ return `
711+ <div>Repos <strong>${ Number ( totals ?. repos ?? top . length ?? 0 ) } </strong> • Open PRs <strong>${ Number ( totals ?. prsOpen ?? 0 ) } </strong></div>
712+ <div>Unreviewed <strong>${ Number ( totals ?. prsUnreviewed ?? 0 ) } </strong> • Needs fix <strong>${ Number ( totals ?. prsNeedsFix ?? 0 ) } </strong></div>
713+ <div style="margin-top:8px; display:flex; flex-direction:column; gap:6px;">
714+ ${ top . length ? top . map ( ( r ) => {
715+ const repo = String ( r ?. repo || '' ) . trim ( ) ;
716+ const open = Number ( r ?. prsOpen ?? 0 ) ;
717+ const unrev = Number ( r ?. prsUnreviewed ?? 0 ) ;
718+ const avgReview = r ?. telemetry ?. avgReviewSeconds ? `${ Math . round ( Number ( r . telemetry . avgReviewSeconds ) ) } s` : '—' ;
719+ const worstRisk = pickWorstRisk ( r ?. riskCounts ) ;
720+ return `
721+ <button class="btn-secondary" type="button" data-open-repo="${ escapeHtml ( repo ) } " title="Open PRs filtered to ${ escapeHtml ( repo ) } " style="width:100%; display:flex; justify-content:space-between; align-items:center; gap:10px;">
722+ <span style="min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${ escapeHtml ( repo ) } (${ open } open, ${ unrev } unrev)</span>
723+ <span style="display:flex; align-items:center; gap:8px; flex-shrink:0;">
724+ ${ worstRisk ? riskChip ( worstRisk ) : '' }
725+ <span style="opacity:0.8;">${ escapeHtml ( avgReview ) } </span>
726+ </span>
727+ </button>
728+ ` ;
729+ } ) . join ( '' ) : `<div style="opacity:0.8;">No PRs found.</div>` }
730+ </div>
731+ ` ;
732+ } ) ( ) ;
733+
734+ projectsEl . innerHTML = `${ boardHtml } ${ prSummaryHtml } ` ;
735+
736+ projectsEl . querySelectorAll ( '[data-dashboard-start-project]' ) . forEach ( ( btn ) => {
737+ btn . addEventListener ( 'click' , async ( ) => {
738+ if ( this . _projectLaunchInFlight ) return ;
739+ const key = String ( btn . getAttribute ( 'data-dashboard-start-project' ) || '' ) . trim ( ) ;
740+ if ( ! key ) return ;
741+ this . _projectLaunchInFlight = true ;
742+ btn . disabled = true ;
743+ try {
744+ const currentId = String ( this . orchestrator ?. currentWorkspace ?. id || '' ) . trim ( ) ;
745+ const workspaces = Array . isArray ( this . workspaces ) ? this . workspaces : [ ] ;
746+ const pickRecent = ( ) => {
747+ if ( currentId ) return currentId ;
748+ let best = null ;
749+ let bestTime = 0 ;
750+ for ( const ws of workspaces ) {
751+ const t = ws ?. lastAccess ? new Date ( ws . lastAccess ) . getTime ( ) : 0 ;
752+ if ( ! best || t > bestTime ) {
753+ best = ws ;
754+ bestTime = t ;
755+ }
756+ }
757+ return String ( best ?. id || '' ) . trim ( ) ;
758+ } ;
759+
760+ const targetId = pickRecent ( ) ;
761+ try { this . orchestrator ?. hideDashboard ?. ( ) ; } catch { }
762+ if ( targetId && targetId !== currentId ) {
763+ this . orchestrator ?. switchToWorkspace ?. ( targetId ) ;
764+ await this . orchestrator ?. waitForWorkspaceActive ?. ( targetId ) . catch ( ( ) => false ) ;
765+ }
766+ await this . orchestrator ?. startProjectWorktreeFromBoardKey ?. ( key ) ;
767+ } catch {
768+ this . orchestrator ?. showToast ?. ( 'Failed to start worktree' , 'error' ) ;
769+ } finally {
770+ btn . disabled = false ;
771+ this . _projectLaunchInFlight = false ;
772+ }
773+ } ) ;
774+ } ) ;
775+
776+ projectsEl . querySelectorAll ( '[data-open-repo]' ) . forEach ( ( btn ) => {
777+ btn . addEventListener ( 'click' , ( ) => {
778+ const repo = btn . getAttribute ( 'data-open-repo' ) || '' ;
779+ if ( ! repo ) return ;
780+ try {
781+ localStorage . setItem ( 'prs-panel-repo' , repo ) ;
782+ } catch { }
783+ try {
784+ this . orchestrator ?. showPRsPanel ?. ( ) ;
785+ } catch { }
786+ } ) ;
787+ } ) ;
788+ }
636789
637790 if ( showReadiness && readinessEl ) {
638791 const data = readinessRes ? await readinessRes . json ( ) . catch ( ( ) => ( { } ) ) : { } ;
0 commit comments