Fix mobile dashboard navigation and connection state handling

This commit is contained in:
Ashish Huddar 2026-03-25 11:41:36 +05:30
parent fc9edb8afb
commit b8eab07d28
20 changed files with 2276 additions and 333 deletions

View File

@ -897,17 +897,128 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
background 0.14s ease;
}
.session-detail-link-pill--link {
color: var(--color-accent-blue);
border-color: color-mix(in srgb, var(--color-accent-blue) 22%, var(--color-border-subtle));
background: color-mix(in srgb, var(--color-accent-blue) 9%, transparent);
text-decoration: none;
box-shadow: inset 0 1px 0 color-mix(in srgb, white 16%, transparent);
}
.session-detail-link-pill:hover {
border-color: var(--color-border-strong);
color: var(--color-text-primary);
}
.session-detail-link-pill--link:hover {
border-color: color-mix(in srgb, var(--color-accent-blue) 44%, var(--color-border-strong));
color: var(--color-accent-blue);
background: color-mix(in srgb, var(--color-accent-blue) 13%, transparent);
}
.session-detail-link-pill--accent {
color: var(--color-accent);
border-color: color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle));
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.session-detail-link-pill--link.session-detail-link-pill--accent {
color: var(--color-accent);
border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border-subtle));
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
}
.session-detail-link-pill--link.session-detail-link-pill--accent:hover {
border-color: color-mix(in srgb, var(--color-accent) 46%, var(--color-border-strong));
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
}
.session-detail-status-pill {
position: relative;
overflow: hidden;
box-shadow: inset 0 1px 0 color-mix(in srgb, white 16%, transparent);
}
.session-detail-status-pill__dot {
position: relative;
z-index: 1;
}
.session-detail-status-pill--active {
animation: session-status-active-breathe 2.8s ease-in-out infinite;
}
.session-detail-status-pill--active .session-detail-status-pill__dot {
animation: session-status-dot-pulse 1.8s ease-in-out infinite;
}
.session-detail-status-pill--ready {
animation: session-status-ready-breathe 3.2s ease-in-out infinite;
}
.session-detail-status-pill--ready .session-detail-status-pill__dot {
animation: session-status-dot-pulse 2.2s ease-in-out infinite;
}
.session-detail-status-pill--idle {
opacity: 0.92;
}
.session-detail-status-pill--waiting {
animation: session-status-waiting-breathe 3s ease-in-out infinite;
}
@keyframes session-status-dot-pulse {
0%, 100% {
transform: scale(1);
opacity: 0.88;
}
50% {
transform: scale(1.18);
opacity: 1;
}
}
@keyframes session-status-active-breathe {
0%, 100% {
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 16%, transparent),
0 0 0 0 color-mix(in srgb, var(--color-status-working) 0%, transparent);
}
50% {
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 18%, transparent),
0 0 0 3px color-mix(in srgb, var(--color-status-working) 10%, transparent);
}
}
@keyframes session-status-ready-breathe {
0%, 100% {
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 16%, transparent),
0 0 0 0 color-mix(in srgb, var(--color-status-ready) 0%, transparent);
}
50% {
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 20%, transparent),
0 0 0 3px color-mix(in srgb, var(--color-status-ready) 12%, transparent);
}
}
@keyframes session-status-waiting-breathe {
0%, 100% {
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 16%, transparent),
0 0 0 0 color-mix(in srgb, var(--color-status-attention) 0%, transparent);
}
50% {
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 18%, transparent),
0 0 0 3px color-mix(in srgb, var(--color-status-attention) 10%, transparent);
}
}
.dark .detail-card {
--color-text-secondary: #9898a0;
--color-text-muted: #5c5c66;
@ -1916,6 +2027,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
color: var(--color-text-primary);
}
.mobile-bottom-nav {
display: none;
}
/* ── Mobile breakpoint — 768px (tablet / landscape phone) ────────────── */
@media (max-width: 767px) {
@ -1959,33 +2074,47 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
padding-top: 12px;
padding-bottom: max(12px, env(safe-area-inset-bottom, 0px));
padding-bottom: calc(84px + env(safe-area-inset-bottom, 0px));
}
/* -- Hero section becomes a lightweight mobile header -- */
.dashboard-hero {
margin-bottom: 8px;
border: none;
background: transparent;
box-shadow: none;
}
/* -- Hero section stacks vertically -- */
.dashboard-hero__content {
flex-direction: row;
flex-wrap: nowrap;
display: grid;
grid-template-columns: 36px minmax(0, 1fr) 36px;
align-items: center;
padding: 0 12px;
padding: 0;
gap: 8px;
min-height: 44px;
min-height: 0;
}
.dashboard-hero__primary {
flex-direction: row;
flex-basis: auto;
flex: 1;
align-items: center;
gap: 8px;
min-height: 44px;
display: block;
min-height: 0;
}
.dashboard-hero__heading {
flex: 1;
min-width: 0;
}
.dashboard-hero__copy {
min-width: 0;
}
.dashboard-title {
font-size: 19px;
line-height: 1;
letter-spacing: -0.04em;
max-width: none;
margin: 0;
}
.dashboard-subtitle {
display: none;
}
@ -1996,8 +2125,90 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
}
.dashboard-hero__meta {
display: flex;
align-items: center;
justify-content: flex-end;
}
.dashboard-hero__meta > div {
gap: 0 !important;
}
.dashboard-hero__meta button {
width: 36px;
height: 36px;
border: none !important;
background: none !important;
color: var(--color-text-secondary);
box-shadow: none !important;
padding: 0;
}
.mobile-menu-toggle {
width: 36px;
height: 36px;
border: none;
background: none;
color: var(--color-text-secondary);
}
.mobile-menu-toggle:hover {
border: none;
background: none;
color: var(--color-text-primary);
}
.mobile-bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 250;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 0;
border-top: 1px solid var(--color-border-default);
background: color-mix(in srgb, var(--color-bg-surface) 92%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
padding: 8px max(12px, env(safe-area-inset-right, 0px))
calc(8px + env(safe-area-inset-bottom, 0px))
max(12px, env(safe-area-inset-left, 0px));
}
.mobile-bottom-nav__item {
display: inline-flex;
min-height: 44px;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.mobile-bottom-nav__item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-left: 0;
}
.mobile-bottom-nav__item[data-active="true"] {
color: var(--color-text-primary);
}
.mobile-bottom-nav__item[aria-current="page"] {
color: var(--color-accent);
}
.mobile-bottom-nav__item:disabled {
color: var(--color-text-tertiary);
cursor: default;
}
/* ── Mobile action strip ─────────────────────────────────────────────── */
@ -2005,12 +2216,69 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.mobile-action-strip {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
flex-shrink: 0;
min-width: 0;
padding-top: 2px;
}
.mobile-priority-row {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
min-width: 0;
margin-bottom: 14px;
}
.mobile-priority-row__label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
.mobile-filter-row {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
margin-bottom: 16px;
padding-bottom: 2px;
}
.mobile-filter-row::-webkit-scrollbar {
display: none;
}
.mobile-filter-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--color-border-default);
background: color-mix(in srgb, var(--color-bg-surface) 88%, transparent);
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition:
border-color 0.12s ease,
background 0.12s ease,
color 0.12s ease;
}
.mobile-filter-chip[data-active="true"] {
border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border-default));
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-accent);
}
.mobile-action-strip::-webkit-scrollbar {
@ -2030,13 +2298,13 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.mobile-action-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
gap: 6px;
padding: 0 12px;
border: 1px solid var(--color-border-default);
background: var(--color-bg-surface);
background: color-mix(in srgb, var(--color-bg-surface) 86%, transparent);
cursor: pointer;
white-space: nowrap;
min-height: 32px;
min-height: 36px;
transition:
background 0.12s ease,
border-color 0.12s ease;
@ -2071,7 +2339,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: lowercase;
}
/* -- Kanban board: stack columns vertically -- */
@ -2142,21 +2409,110 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
padding-right: 12px;
}
.session-page-header {
padding: 12px;
.session-detail-page {
background: var(--color-bg-base);
}
.session-page-header__meta {
flex-direction: column;
.session-page-header {
gap: 6px;
padding: 6px 0 8px;
border: none;
background: transparent;
box-shadow: none;
}
.session-page-header__main {
gap: 6px;
}
.session-page-header__crumbs {
gap: 6px;
padding-bottom: 0;
border-bottom: none;
font-size: 11px;
}
.session-page-header__mode {
padding: 0;
border: none;
background: none;
font-size: 10px;
letter-spacing: 0.08em;
}
.session-page-header__meta {
flex-direction: row;
gap: 6px;
margin-top: 6px;
}
.session-page-header__side {
gap: 8px;
}
.session-page-header h1 {
font-size: 16px;
line-height: 1.12;
letter-spacing: -0.04em;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.session-page-header--mobile .session-page-header__identity {
display: grid;
gap: 0;
}
.session-detail-link-pill {
min-height: 36px;
min-height: 28px;
padding: 0 8px;
font-size: 10px;
}
.session-page-header__side .flex.items-baseline {
gap: 4px;
margin-right: 0 !important;
}
.session-page-header__side .text-\[22px\] {
font-size: 15px !important;
}
.session-page-header__side .text-\[11px\] {
font-size: 10px !important;
}
.session-page-header__side .h-5 {
display: none;
}
.session-page-header__side > div:last-child {
display: flex;
flex-wrap: wrap;
gap: 6px !important;
width: 100%;
}
.session-page-header__side > div:last-child > div {
padding: 0 8px !important;
min-height: 26px;
align-items: center;
}
.session-page-header__side > div:last-child > div span:first-child {
font-size: 12px !important;
}
.session-page-header__side > div:last-child > div span:last-child {
font-size: 9px !important;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.session-detail-page section.mt-5 {
margin-top: 6px;
}
/* -- Touch targets for interactive elements -- */
@ -2200,38 +2556,220 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 8px;
margin-top: 0;
scroll-margin-top: 56px;
}
.accordion-section {
border: 1px solid var(--color-border-default);
background: var(--color-bg-surface);
overflow: hidden;
transition: background 0.15s ease;
border: 1px solid color-mix(in srgb, var(--color-border-subtle) 58%, transparent);
background: color-mix(in srgb, var(--color-bg-surface) 78%, var(--color-bg-base));
overflow: visible;
}
.accordion-section + .accordion-section {
margin-top: 6px;
margin-top: 18px;
padding-top: 0;
border-top: none;
}
.accordion-section--expanded {
border-color: color-mix(in srgb, var(--color-border-default) 82%, transparent);
background: color-mix(in srgb, var(--color-bg-surface) 88%, var(--color-bg-base));
}
.mobile-session-list {
display: flex;
flex-direction: column;
border-top: 1px solid color-mix(in srgb, var(--color-border-default) 68%, transparent);
}
.mobile-session-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 10px;
width: 100%;
min-width: 0;
min-height: 58px;
padding: 11px 12px;
background: color-mix(in srgb, var(--color-bg-surface) 96%, transparent);
border: none;
border-top: 1px solid color-mix(in srgb, var(--color-border-subtle) 70%, transparent);
text-align: left;
transition: background 0.12s ease;
}
.mobile-session-row:first-child {
border-top: none;
}
.mobile-session-row:active {
background: color-mix(in srgb, var(--color-bg-hover) 82%, var(--color-bg-surface));
}
.mobile-session-row__preview {
display: flex;
flex: 1 1 0%;
flex-direction: column;
gap: 5px;
min-width: 0;
overflow: hidden;
background: none;
border: none;
padding: 0;
text-align: left;
cursor: pointer;
}
.mobile-session-row__line {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 8px;
width: 100%;
min-width: 0;
overflow: hidden;
}
.mobile-session-row__dot {
width: 7px;
height: 7px;
border-radius: 999px;
flex-shrink: 0;
margin-top: 5px;
}
.mobile-session-row__title {
display: block;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
.mobile-session-row__chip {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
max-width: min(14ch, 28vw);
min-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 6px;
border: 1px solid color-mix(in srgb, var(--color-border-default) 88%, transparent);
background: color-mix(in srgb, var(--color-bg-surface) 92%, var(--color-bg-subtle));
font-size: 10px;
font-weight: 600;
line-height: 1;
text-align: right;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-secondary);
}
.mobile-session-row__meta {
padding-left: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
line-height: 1.2;
color: var(--color-text-muted);
letter-spacing: 0.01em;
}
.mobile-session-row__side {
display: grid;
grid-template-columns: max-content 28px;
align-items: start;
justify-content: end;
gap: 10px;
align-self: start;
flex-shrink: 0;
}
.mobile-session-row__open {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: start;
width: 30px;
min-width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
flex-shrink: 0;
color: var(--color-accent);
text-decoration: none;
border: 1px solid color-mix(in srgb, var(--color-accent) 26%, var(--color-border-default));
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-surface));
transition:
background 0.12s ease,
border-color 0.12s ease,
color 0.12s ease,
transform 0.12s ease;
}
.mobile-session-row__open:hover,
.mobile-session-row__open:focus-visible {
border-color: color-mix(in srgb, var(--color-accent) 44%, var(--color-border-default));
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-surface));
outline: none;
}
.mobile-session-row__open:active {
transform: translateY(1px);
background: color-mix(in srgb, var(--color-accent) 16%, var(--color-bg-hover));
}
.mobile-session-row__open-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.mobile-session-list__view-all {
min-height: 44px;
padding: 0 0 0 15px;
background: none;
border: none;
border-top: 1px solid color-mix(in srgb, var(--color-border-subtle) 84%, transparent);
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--color-accent);
cursor: pointer;
}
.mobile-session-list__empty {
min-height: 44px;
display: flex;
align-items: center;
padding: 0 12px 0 15px;
color: var(--color-text-muted);
font-size: 12px;
}
.accordion-header {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
width: 100%;
min-height: 44px;
padding: 0 14px;
min-height: 0;
padding: 10px 12px;
background: none;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-text-primary);
transition: background 0.12s ease;
}
.accordion-header:hover {
background: var(--color-bg-hover);
}
.accordion-header__dot {
@ -2242,26 +2780,38 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
}
.accordion-header__label {
font-size: 14px;
font-size: 16px;
font-weight: 600;
flex: 1;
color: var(--color-text-muted);
letter-spacing: -0.03em;
}
.accordion-section--expanded .accordion-header__label {
color: var(--color-text-primary);
}
.accordion-section--collapsed .accordion-header {
background: color-mix(in srgb, var(--color-bg-surface) 72%, var(--color-bg-base));
}
.accordion-section--expanded .accordion-header {
background: color-mix(in srgb, var(--color-bg-surface) 92%, var(--color-bg-base));
}
.accordion-header__count {
font-size: 12px;
font-weight: 500;
min-width: 20px;
padding: 2px 6px;
background: color-mix(in srgb, var(--color-border-default) 60%, transparent);
border-radius: 999px;
font-size: 13px;
font-weight: 600;
min-width: 0;
padding: 0;
background: none;
text-align: center;
color: var(--color-text-secondary);
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.accordion-header__chevron {
font-size: 10px;
font-size: 9px;
color: var(--color-text-tertiary);
flex-shrink: 0;
transition: transform 0.25s ease;
@ -2356,6 +2906,78 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
height: 80px;
overflow: auto;
}
.mobile-pr-list {
display: flex;
flex-direction: column;
border: 1px solid color-mix(in srgb, var(--color-border-default) 82%, transparent);
background: var(--color-bg-surface);
}
.mobile-pr-card {
display: flex;
flex-direction: column;
gap: 5px;
padding: 11px 12px;
border-top: 1px solid var(--color-border-subtle);
color: var(--color-text-primary);
text-decoration: none;
}
.mobile-pr-card:first-child {
border-top: none;
}
.mobile-pr-card__line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.mobile-pr-card__number {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
color: var(--color-accent);
}
.mobile-pr-card__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 600;
}
.mobile-pr-card__size {
flex-shrink: 0;
padding: 1px 6px;
border: 1px solid var(--color-border-default);
background: var(--color-bg-subtle);
font-size: 10px;
font-weight: 700;
color: var(--color-text-secondary);
}
.mobile-pr-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 10px;
color: var(--color-text-muted);
}
.mx-auto.max-w-\[900px\] > h2 {
margin-bottom: 8px;
padding-left: 0;
font-size: 11px;
color: var(--color-text-secondary);
letter-spacing: 0.12em;
}
}
/* ── Phone breakpoint — 480px ────────────────────────────────────────── */
@ -2492,7 +3114,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: var(--z-modal);
z-index: calc(var(--z-overlay) + 20);
}
.bottom-sheet {
@ -2502,8 +3124,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
right: 0;
background: var(--color-bg-surface);
border-top: 1px solid var(--color-border-default);
padding: 16px 20px calc(env(safe-area-inset-bottom, 0px) + 24px);
z-index: calc(var(--z-modal) + 1);
border-bottom: none;
padding: 16px 20px calc(12px + env(safe-area-inset-bottom, 0px));
z-index: calc(var(--z-overlay) + 21);
transform: translateY(0);
animation: sheet-in 0.25s ease forwards;
}
@ -2536,7 +3159,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.bottom-sheet__session-info {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-subtle);
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 20px;
}
@ -2555,12 +3177,42 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
display: flex;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
color: var(--color-text-muted);
}
.bottom-sheet__session-branch {
font-family: monospace;
.bottom-sheet__tag {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border: 1px solid var(--color-border-default);
background: var(--color-bg-surface);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.bottom-sheet__tag--accent {
color: var(--color-accent);
border-color: color-mix(in srgb, var(--color-accent) 24%, var(--color-border-default));
}
.bottom-sheet__tag--mono {
font-family: var(--font-mono);
text-transform: none;
letter-spacing: 0;
}
.bottom-sheet__summary {
margin: 10px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-secondary);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
.bottom-sheet__actions {
@ -2571,7 +3223,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.bottom-sheet__btn {
flex: 1;
min-height: 44px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
@ -2579,6 +3234,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
transition: opacity 0.15s ease;
}
.bottom-sheet__btn-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.bottom-sheet__btn:active {
opacity: 0.75;
}
@ -2602,6 +3263,29 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
opacity: 0.9;
}
.bottom-sheet__btn--primary {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-accent);
border-color: var(--color-accent);
color: #ffffff;
text-decoration: none;
}
.bottom-sheet__btn--primary:hover {
opacity: 0.92;
}
.bottom-sheet__btn--secondary {
background: transparent;
color: var(--color-text-primary);
}
.bottom-sheet__btn--secondary:hover {
background: var(--color-bg-subtle);
}
/* ─── Connection Status Bar ───────────────────────────────────────────────── */
.connection-bar {

View File

@ -2,136 +2,27 @@ import type { Metadata } from "next";
export const dynamic = "force-dynamic";
import { Dashboard } from "@/components/Dashboard";
import type { DashboardSession } from "@/lib/types";
import { getServices, getSCM } from "@/lib/services";
import {
sessionToDashboard,
resolveProject,
enrichSessionPR,
enrichSessionsMetadata,
listDashboardOrchestrators,
} from "@/lib/serialize";
import { prCache, prCacheKey } from "@/lib/cache";
import { getPrimaryProjectId, getProjectName, getAllProjects } from "@/lib/project-name";
import { filterProjectSessions, filterWorkerSessions } from "@/lib/project-utils";
import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause";
function getSelectedProjectName(projectFilter: string | undefined): string {
if (projectFilter === "all") return "All Projects";
const projects = getAllProjects();
if (projectFilter) {
const selectedProject = projects.find((project) => project.id === projectFilter);
if (selectedProject) return selectedProject.name;
}
return getProjectName();
}
import { getDashboardPageData, resolveDashboardProjectFilter } from "@/lib/dashboard-page-data";
export async function generateMetadata(props: {
searchParams: Promise<{ project?: string }>;
}): Promise<Metadata> {
const searchParams = await props.searchParams;
const projectFilter = searchParams.project ?? getPrimaryProjectId();
const projectName = getSelectedProjectName(projectFilter);
return { title: { absolute: `ao | ${projectName}` } };
const projectFilter = resolveDashboardProjectFilter(searchParams.project);
const pageData = await getDashboardPageData(projectFilter);
return { title: { absolute: `ao | ${pageData.projectName}` } };
}
export default async function Home(props: { searchParams: Promise<{ project?: string }> }) {
const searchParams = await props.searchParams;
const projectFilter = searchParams.project ?? getPrimaryProjectId();
const pageData: {
sessions: DashboardSession[];
globalPause: GlobalPauseState | null;
orchestrators: Array<{ id: string; projectId: string; projectName: string }>;
} = {
sessions: [],
globalPause: null,
orchestrators: [],
};
try {
const { config, registry, sessionManager } = await getServices();
const allSessions = await sessionManager.list();
pageData.globalPause = resolveGlobalPause(allSessions);
const visibleSessions = filterProjectSessions(allSessions, projectFilter, config.projects);
pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects);
const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects);
pageData.sessions = coreSessions.map(sessionToDashboard);
const metaTimeout = new Promise<void>((resolve) => setTimeout(resolve, 3_000));
await Promise.race([
enrichSessionsMetadata(coreSessions, pageData.sessions, config, registry),
metaTimeout,
]);
const terminalStatuses = new Set(["merged", "killed", "cleanup", "done", "terminated"]);
const enrichPromises = coreSessions.map((core, i) => {
if (!core.pr) return Promise.resolve();
const cacheKey = prCacheKey(core.pr.owner, core.pr.repo, core.pr.number);
const cached = prCache.get(cacheKey);
if (cached) {
if (pageData.sessions[i].pr) {
pageData.sessions[i].pr.state = cached.state;
pageData.sessions[i].pr.title = cached.title;
pageData.sessions[i].pr.additions = cached.additions;
pageData.sessions[i].pr.deletions = cached.deletions;
pageData.sessions[i].pr.ciStatus = cached.ciStatus as
| "none"
| "pending"
| "passing"
| "failing";
pageData.sessions[i].pr.reviewDecision = cached.reviewDecision as
| "none"
| "pending"
| "approved"
| "changes_requested";
pageData.sessions[i].pr.ciChecks = cached.ciChecks.map((c) => ({
name: c.name,
status: c.status as "pending" | "running" | "passed" | "failed" | "skipped",
url: c.url,
}));
pageData.sessions[i].pr.mergeability = cached.mergeability;
pageData.sessions[i].pr.unresolvedThreads = cached.unresolvedThreads;
pageData.sessions[i].pr.unresolvedComments = cached.unresolvedComments;
}
if (
terminalStatuses.has(core.status) ||
cached.state === "merged" ||
cached.state === "closed"
) {
return Promise.resolve();
}
}
const project = resolveProject(core, config.projects);
const scm = getSCM(registry, project);
if (!scm) return Promise.resolve();
return enrichSessionPR(pageData.sessions[i], scm, core.pr);
});
const enrichTimeout = new Promise<void>((resolve) => setTimeout(resolve, 4_000));
await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]);
} catch {
pageData.sessions = [];
pageData.globalPause = null;
pageData.orchestrators = [];
}
const projectName = getSelectedProjectName(projectFilter);
const projects = getAllProjects();
const selectedProjectId = projectFilter === "all" ? undefined : projectFilter;
const pageData = await getDashboardPageData(searchParams.project);
return (
<Dashboard
initialSessions={pageData.sessions}
projectId={selectedProjectId}
projectName={projectName}
projects={projects}
projectId={pageData.selectedProjectId}
projectName={pageData.projectName}
projects={pageData.projects}
initialGlobalPause={pageData.globalPause}
orchestrators={pageData.orchestrators}
/>

View File

@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { PullRequestsPage } from "@/components/PullRequestsPage";
import { getDashboardPageData, resolveDashboardProjectFilter } from "@/lib/dashboard-page-data";
export const dynamic = "force-dynamic";
export async function generateMetadata(props: {
searchParams: Promise<{ project?: string }>;
}): Promise<Metadata> {
const searchParams = await props.searchParams;
const projectFilter = resolveDashboardProjectFilter(searchParams.project);
const pageData = await getDashboardPageData(projectFilter);
return { title: { absolute: `ao | ${pageData.projectName} PRs` } };
}
export default async function PullRequestsRoute(props: {
searchParams: Promise<{ project?: string }>;
}) {
const searchParams = await props.searchParams;
const pageData = await getDashboardPageData(searchParams.project);
return (
<PullRequestsPage
initialSessions={pageData.sessions}
projectId={pageData.selectedProjectId}
projectName={pageData.projectName}
projects={pageData.projects}
orchestrators={pageData.orchestrators}
/>
);
}

View File

@ -41,12 +41,19 @@ interface ZoneCounts {
done: number;
}
interface ProjectSessionsBody {
sessions: DashboardSession[];
orchestratorId?: string | null;
orchestrators?: Array<{ id: string; projectId: string; projectName: string }>;
}
export default function SessionPage() {
const params = useParams();
const id = params.id as string;
const [session, setSession] = useState<DashboardSession | null>(null);
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(null);
const [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const sessionProjectId = session?.projectId ?? null;
@ -82,13 +89,21 @@ export default function SessionPage() {
}
}, [id]);
const fetchZoneCounts = useCallback(async () => {
if (!sessionIsOrchestrator || !sessionProjectId) return;
const fetchProjectSessions = useCallback(async () => {
if (!sessionProjectId) return;
try {
const res = await fetch(`/api/sessions?project=${encodeURIComponent(sessionProjectId)}`);
if (!res.ok) return;
const body = (await res.json()) as { sessions: DashboardSession[] };
const body = (await res.json()) as ProjectSessionsBody;
const sessions = body.sessions ?? [];
const orchestratorId =
body.orchestratorId ??
body.orchestrators?.find((orchestrator) => orchestrator.projectId === sessionProjectId)?.id ??
null;
setProjectOrchestratorId(orchestratorId);
if (!sessionIsOrchestrator) return;
const counts: ZoneCounts = {
merge: 0,
respond: 0,
@ -112,18 +127,18 @@ export default function SessionPage() {
useEffect(() => {
fetchSession();
// Delay zone counts so the heavy /api/sessions call doesn't contend with session load
const t = setTimeout(fetchZoneCounts, 2000);
const t = setTimeout(fetchProjectSessions, 2000);
return () => clearTimeout(t);
}, [fetchSession, fetchZoneCounts]);
}, [fetchSession, fetchProjectSessions]);
// Poll every 5s
useEffect(() => {
const interval = setInterval(() => {
fetchSession();
fetchZoneCounts();
fetchProjectSessions();
}, 5000);
return () => clearInterval(interval);
}, [fetchSession, fetchZoneCounts]);
}, [fetchSession, fetchProjectSessions]);
if (loading) {
return (
@ -151,6 +166,7 @@ export default function SessionPage() {
session={session}
isOrchestrator={sessionIsOrchestrator}
orchestratorZones={zoneCounts ?? undefined}
projectOrchestratorId={projectOrchestratorId}
/>
);
}

View File

@ -1,8 +1,13 @@
"use client";
import { memo } from "react";
import type { DashboardSession, AttentionLevel } from "@/lib/types";
import { memo, useState } from "react";
import {
type DashboardSession,
type AttentionLevel,
isPRMergeReady,
} from "@/lib/types";
import { SessionCard } from "./SessionCard";
import { getSessionTitle } from "@/lib/format";
interface AttentionZoneProps {
level: AttentionLevel;
@ -15,6 +20,10 @@ interface AttentionZoneProps {
collapsed?: boolean;
/** Accordion mode: called when the header is tapped to toggle */
onToggle?: (level: AttentionLevel) => void;
/** Dense mobile rows rendered instead of full cards */
compactMobile?: boolean;
/** Open the lightweight mobile preview sheet */
onPreview?: (session: DashboardSession) => void;
}
const zoneConfig: Record<
@ -75,9 +84,15 @@ function AttentionZoneView({
onRestore,
collapsed,
onToggle,
compactMobile,
onPreview,
}: AttentionZoneProps) {
const config = zoneConfig[level];
const isAccordion = onToggle !== undefined;
const [showAll, setShowAll] = useState(false);
const visibleSessions =
isAccordion && compactMobile && !showAll ? sessions.slice(0, 5) : sessions;
const hiddenCount = sessions.length - visibleSessions.length;
if (isAccordion) {
return (
@ -98,20 +113,42 @@ function AttentionZoneView({
</button>
<div className="accordion-body">
{sessions.length > 0 && (
<div className="flex flex-col gap-2 p-3">
{sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onSend={onSend}
onKill={onKill}
onMerge={onMerge}
onRestore={onRestore}
/>
))}
{sessions.length > 0 ? (
<div className={compactMobile ? "mobile-session-list" : "flex flex-col gap-2 p-3"}>
{visibleSessions.map((session) =>
compactMobile ? (
<MobileSessionRow
key={session.id}
session={session}
level={level}
onPreview={onPreview}
/>
) : (
<SessionCard
key={session.id}
session={session}
onSend={onSend}
onKill={onKill}
onMerge={onMerge}
onRestore={onRestore}
/>
),
)}
{compactMobile && hiddenCount > 0 ? (
<button
type="button"
className="mobile-session-list__view-all"
onClick={() => setShowAll(true)}
>
View all {sessions.length}
</button>
) : null}
</div>
)}
) : compactMobile ? (
<div className="mobile-session-list">
<div className="mobile-session-list__empty">No sessions</div>
</div>
) : null}
</div>
</div>
);
@ -161,9 +198,94 @@ function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZon
prev.onKill === next.onKill &&
prev.onMerge === next.onMerge &&
prev.onRestore === next.onRestore &&
prev.compactMobile === next.compactMobile &&
prev.onPreview === next.onPreview &&
prev.sessions.length === next.sessions.length &&
prev.sessions.every((session, index) => session === next.sessions[index])
);
}
export const AttentionZone = memo(AttentionZoneView, areAttentionZonePropsEqual);
function MobileSessionRow({
session,
level,
onPreview,
}: {
session: DashboardSession;
level: AttentionLevel;
onPreview?: (session: DashboardSession) => void;
}) {
const meta = [
session.branch,
session.pr ? `PR #${session.pr.number}` : null,
session.issueLabel,
].filter(Boolean);
return (
<div className="mobile-session-row">
<button
type="button"
className="mobile-session-row__preview"
onClick={() => onPreview?.(session)}
aria-label={`Open ${getSessionTitle(session)}`}
>
<div className="mobile-session-row__line">
<span
className="mobile-session-row__dot"
style={{ background: zoneConfig[level].color }}
aria-hidden="true"
/>
<span className="mobile-session-row__title">{getSessionTitle(session)}</span>
</div>
<div className="mobile-session-row__meta">
{meta.length > 0 ? meta.join(" · ") : "No branch or PR metadata"}
</div>
</button>
<div className="mobile-session-row__side">
<SessionStateChip session={session} level={level} />
<a
href={`/sessions/${encodeURIComponent(session.id)}`}
className="mobile-session-row__open"
aria-label={`Go to ${getSessionTitle(session)}`}
>
<svg
className="mobile-session-row__open-icon"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 17V7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z" />
<path d="m8 10 2 2-2 2M12 14h4" />
</svg>
</a>
</div>
</div>
);
}
function SessionStateChip({
session,
level,
}: {
session: DashboardSession;
level: AttentionLevel;
}) {
let label = zoneConfig[level].label.toLowerCase();
if (level === "merge" && session.pr && isPRMergeReady(session.pr)) {
label = "ready";
} else if (level === "respond") {
label = session.activity === "waiting_input" ? "waiting" : "needs input";
} else if (level === "review") {
label = session.pr?.reviewDecision === "changes_requested" ? "changes" : "review";
} else if (level === "pending") {
label = session.pr?.unresolvedThreads ? "threads" : "pending";
} else if (level === "working") {
label = session.activity === "idle" ? "idle" : "active";
}
return <span className="mobile-session-row__chip">{label}</span>;
}

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import type { DashboardSession } from "@/lib/types";
import { getAttentionLevel, type DashboardSession } from "@/lib/types";
import { getSessionTitle } from "@/lib/format";
function getRelativeTime(dateStr: string): string {
@ -18,13 +18,40 @@ function getRelativeTime(dateStr: string): string {
return `${diffDay}d ago`;
}
interface BottomSheetProps {
session: DashboardSession | null;
onConfirm: () => void;
onCancel: () => void;
function formatTagLabel(value: string): string {
return value.replaceAll("_", " ");
}
export function BottomSheet({ session, onCancel, onConfirm }: BottomSheetProps) {
function isTag(
value:
| {
label: string;
tone: "accent" | "neutral" | "mono";
}
| null,
): value is { label: string; tone: "accent" | "neutral" | "mono" } {
return value !== null;
}
interface BottomSheetProps {
session: DashboardSession | null;
mode: "preview" | "confirm-kill";
onConfirm: () => void;
onCancel: () => void;
onRequestKill?: () => void;
onMerge?: (prNumber: number) => void;
isMergeReady?: boolean;
}
export function BottomSheet({
session,
mode,
onCancel,
onConfirm,
onRequestKill,
onMerge,
isMergeReady = false,
}: BottomSheetProps) {
const touchStartYRef = useRef<number | null>(null);
const sheetRef = useRef<HTMLDivElement>(null);
@ -83,8 +110,19 @@ export function BottomSheet({ session, onCancel, onConfirm }: BottomSheetProps)
if (!session) return null;
const title = getSessionTitle(session);
const branch = session.branch ?? "—";
const runtime = getRelativeTime(session.createdAt);
const attention = getAttentionLevel(session);
const summary =
session.summary && !session.summaryIsFallback ? session.summary : null;
const hasLiveTerminateAction =
attention !== "done" && attention !== "merge" && session.status !== "terminated";
const tags = [
{ label: formatTagLabel(attention), tone: "accent" as const },
{ label: formatTagLabel(session.status), tone: "neutral" as const },
session.activity ? { label: formatTagLabel(session.activity), tone: "neutral" as const } : null,
session.branch ? { label: session.branch, tone: "mono" as const } : null,
session.pr ? { label: `PR #${session.pr.number}`, tone: "neutral" as const } : null,
session.issueLabel ? { label: session.issueLabel, tone: "neutral" as const } : null,
].filter(isTag);
return (
<>
@ -110,36 +148,90 @@ export function BottomSheet({ session, onCancel, onConfirm }: BottomSheetProps)
<div className="bottom-sheet__header">
<h2 id="bottom-sheet-title" className="bottom-sheet__title">
Terminate session?
{mode === "confirm-kill" ? "Terminate session?" : title}
</h2>
<p className="bottom-sheet__subtitle">This action cannot be undone.</p>
<p className="bottom-sheet__subtitle">
{mode === "confirm-kill"
? "This action cannot be undone."
: `${attention} · started ${getRelativeTime(session.createdAt)}`}
</p>
</div>
<div className="bottom-sheet__session-info">
<div className="bottom-sheet__session-name">{title}</div>
{mode === "confirm-kill" ? (
<div className="bottom-sheet__session-name">{title}</div>
) : null}
<div className="bottom-sheet__session-meta">
{branch !== "—" && (
<span className="bottom-sheet__session-branch">{branch}</span>
)}
<span className="bottom-sheet__session-runtime">Started {runtime}</span>
{tags.map((tag) => (
<span
key={`${tag.tone}-${tag.label}`}
className={`bottom-sheet__tag bottom-sheet__tag--${tag.tone}`}
>
{tag.label}
</span>
))}
</div>
{summary ? <p className="bottom-sheet__summary">{summary}</p> : null}
</div>
<div className="bottom-sheet__actions">
<button
type="button"
className="bottom-sheet__btn bottom-sheet__btn--cancel"
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
className="bottom-sheet__btn bottom-sheet__btn--danger"
onClick={onConfirm}
>
Terminate
</button>
{mode === "confirm-kill" ? (
<>
<button
type="button"
className="bottom-sheet__btn bottom-sheet__btn--cancel"
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
className="bottom-sheet__btn bottom-sheet__btn--danger"
onClick={onConfirm}
>
Terminate
</button>
</>
) : (
<>
<a
href={`/sessions/${encodeURIComponent(session.id)}`}
className="bottom-sheet__btn bottom-sheet__btn--primary"
>
Open session
</a>
{isMergeReady && session.pr && onMerge ? (
<button
type="button"
className="bottom-sheet__btn bottom-sheet__btn--secondary"
onClick={() => onMerge(session.pr!.number)}
>
Merge
</button>
) : hasLiveTerminateAction && onRequestKill ? (
<button
type="button"
className="bottom-sheet__btn bottom-sheet__btn--danger"
onClick={onRequestKill}
>
<svg
className="bottom-sheet__btn-icon"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
Terminate
</button>
) : null}
</>
)}
</div>
</div>
</>

View File

@ -6,16 +6,14 @@ import { useMediaQuery } from "@/hooks/useMediaQuery";
import {
type DashboardSession,
type DashboardStats,
type DashboardPR,
type AttentionLevel,
type GlobalPauseState,
type DashboardOrchestratorLink,
getAttentionLevel,
isPRRateLimited,
CI_STATUS,
isPRMergeReady,
} from "@/lib/types";
import { AttentionZone } from "./AttentionZone";
import { PRTableRow } from "./PRStatus";
import { DynamicFavicon } from "./DynamicFavicon";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { ProjectSidebar } from "./ProjectSidebar";
@ -25,6 +23,7 @@ import { EmptyState } from "./Skeleton";
import { ToastProvider, useToast } from "./Toast";
import { BottomSheet } from "./BottomSheet";
import { ConnectionBar } from "./ConnectionBar";
import { MobileBottomNav } from "./MobileBottomNav";
interface DashboardProps {
initialSessions: DashboardSession[];
@ -38,6 +37,16 @@ interface DashboardProps {
const KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const;
/** Urgency-first order for the mobile accordion (reversed from desktop) */
const MOBILE_KANBAN_ORDER = ["respond", "merge", "review", "pending", "working"] as const;
const MOBILE_FILTERS = [
{ value: "all", label: "All" },
{ value: "respond", label: "Respond" },
{ value: "merge", label: "Ready" },
{ value: "review", label: "Review" },
{ value: "pending", label: "Pending" },
{ value: "working", label: "Working" },
] as const;
type MobileAttentionLevel = (typeof MOBILE_KANBAN_ORDER)[number];
type MobileFilterValue = (typeof MOBILE_FILTERS)[number]["value"];
const EMPTY_ORCHESTRATORS: DashboardOrchestratorLink[] = [];
function mergeOrchestrators(
@ -53,6 +62,13 @@ function mergeOrchestrators(
return [...merged.values()];
}
function getProjectScopedHref(basePath: "/" | "/prs", projectId: string | undefined): string {
if (projectId) {
return `${basePath}?project=${encodeURIComponent(projectId)}`;
}
return `${basePath}?project=all`;
}
function DashboardInner({
initialSessions,
projectId,
@ -78,13 +94,29 @@ function DashboardInner({
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobile = useMediaQuery(767);
const [expandedLevel, setExpandedLevel] = useState<AttentionLevel | null>(null);
const [expandedLevel, setExpandedLevel] = useState<MobileAttentionLevel | null>(null);
const [mobileFilter, setMobileFilter] = useState<MobileFilterValue>("all");
const showSidebar = projects.length > 1;
const { showToast } = useToast();
const [killTargetSession, setKillTargetSession] = useState<DashboardSession | null>(null);
const [sheetState, setSheetState] = useState<{
session: DashboardSession;
mode: "preview" | "confirm-kill";
} | null>(null);
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const allProjectsView = showSidebar && projectId === undefined;
const currentProjectOrchestrator = useMemo(
() =>
projectId
? activeOrchestrators.find((orchestrator) => orchestrator.projectId === projectId) ?? null
: null,
[activeOrchestrators, projectId],
);
const dashboardHref = getProjectScopedHref("/", projectId);
const prsHref = getProjectScopedHref("/prs", projectId);
const orchestratorHref = currentProjectOrchestrator
? `/sessions/${encodeURIComponent(currentProjectOrchestrator.id)}`
: null;
const displaySessions = useMemo(() => {
if (allProjectsView || !activeSessionId) return sessions;
@ -124,6 +156,15 @@ function DashboardInner({
// eslint-disable-next-line react-hooks/exhaustive-deps — intentionally seeded once per mobile mode change, not on every session update
}, [isMobile]);
useEffect(() => {
if (!isMobile) return;
if (mobileFilter === "all") {
setExpandedLevel(MOBILE_KANBAN_ORDER.find((level) => grouped[level].length > 0) ?? null);
return;
}
setExpandedLevel(mobileFilter);
}, [grouped, isMobile, mobileFilter]);
const sessionsByProject = useMemo(() => {
const groupedSessions = new Map<string, DashboardSession[]>();
for (const session of sessions) {
@ -137,16 +178,6 @@ function DashboardInner({
return groupedSessions;
}, [sessions]);
const openPRs = useMemo(() => {
return displaySessions
.filter(
(session): session is DashboardSession & { pr: DashboardPR } =>
session.pr?.state === "open",
)
.map((session) => session.pr)
.sort((a, b) => mergeScore(a) - mergeScore(b));
}, [displaySessions]);
const projectOverviews = useMemo(() => {
if (!allProjectsView) return [];
@ -177,10 +208,13 @@ function DashboardInner({
}, [activeOrchestrators, allProjectsView, projects, sessionsByProject]);
const handleAccordionToggle = useCallback((level: AttentionLevel) => {
if (level === "done") return;
setExpandedLevel((current) => (current === level ? null : level));
}, []);
const handlePillTap = useCallback((level: AttentionLevel) => {
if (level === "done") return;
setMobileFilter(level);
setExpandedLevel(level);
const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches
? ("instant" as ScrollBehavior)
@ -188,6 +222,9 @@ function DashboardInner({
document.getElementById("mobile-board")?.scrollIntoView({ behavior, block: "start" });
}, []);
const visibleMobileLevels =
mobileFilter === "all" ? MOBILE_KANBAN_ORDER : MOBILE_KANBAN_ORDER.filter((level) => level === mobileFilter);
const handleSend = useCallback(async (sessionId: string, message: string) => {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
method: "POST",
@ -201,12 +238,23 @@ function DashboardInner({
const handleKill = useCallback((sessionId: string) => {
const session = sessionsRef.current.find((s) => s.id === sessionId) ?? null;
setKillTargetSession(session);
if (!session) return;
setSheetState({ session, mode: "confirm-kill" });
}, []);
const handlePreview = useCallback((session: DashboardSession) => {
setSheetState({ session, mode: "preview" });
}, []);
const handleRequestKillFromPreview = useCallback(() => {
setSheetState((current) =>
current ? { session: current.session, mode: "confirm-kill" } : current,
);
}, []);
const handleKillConfirm = useCallback(async () => {
const session = killTargetSession;
setKillTargetSession(null);
const session = sheetState?.session;
setSheetState(null);
if (!session) return;
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/kill`, {
method: "POST",
@ -218,9 +266,10 @@ function DashboardInner({
} else {
showToast("Session terminated", "success");
}
}, [killTargetSession, showToast]);
}, [sheetState, showToast]);
const handleMerge = useCallback(async (prNumber: number) => {
setSheetState(null);
const res = await fetch(`/api/prs/${prNumber}/merge`, { method: "POST" });
if (!res.ok) {
const text = await res.text();
@ -331,6 +380,7 @@ function DashboardInner({
/>
)}
<div className="dashboard-main flex-1 overflow-y-auto px-4 py-4 md:px-7 md:py-6">
<div id="mobile-dashboard-anchor" aria-hidden="true" />
<DynamicFavicon sessions={sessions} projectName={projectName} />
<section className="dashboard-hero mb-5">
<div className="dashboard-hero__backdrop" />
@ -355,32 +405,55 @@ function DashboardInner({
)}
<div className="dashboard-hero__primary">
<div className="dashboard-hero__heading">
<div>
<h1 className="dashboard-title">{projectName ?? "Orchestrator"}</h1>
<div className="dashboard-hero__copy">
<h1 className="dashboard-title">
{isMobile ? "agent-orchestrator" : (projectName ?? "Orchestrator")}
</h1>
<p className="dashboard-subtitle">
Live sessions, review pressure, and merge readiness.
</p>
</div>
</div>
{isMobile ? (
<MobileActionStrip
grouped={grouped}
onPillTap={handlePillTap}
/>
) : (
<StatusCards stats={liveStats} />
)}
{!isMobile ? <StatusCards stats={liveStats} /> : null}
</div>
<div className="dashboard-hero__meta">
<div className="flex items-center gap-3">
{!allProjectsView && <OrchestratorControl orchestrators={activeOrchestrators} />}
{!allProjectsView && !isMobile ? (
<OrchestratorControl orchestrators={activeOrchestrators} />
) : null}
<ThemeToggle />
</div>
</div>
</div>
</section>
{isMobile ? (
<section className="mobile-priority-row" aria-label="Needs attention">
<div className="mobile-priority-row__label">Needs attention</div>
<MobileActionStrip
grouped={grouped}
onPillTap={handlePillTap}
/>
</section>
) : null}
{isMobile ? (
<section className="mobile-filter-row" aria-label="Dashboard filters">
{MOBILE_FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
className="mobile-filter-chip"
data-active={mobileFilter === filter.value ? "true" : "false"}
onClick={() => setMobileFilter(filter.value)}
>
{filter.label}
</button>
))}
</section>
) : null}
{globalPause && !globalPauseDismissed && (
<div className="dashboard-alert mb-6 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-error)_25%,transparent)] bg-[var(--color-tint-red)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-error)]">
<svg
@ -481,7 +554,7 @@ function DashboardInner({
{isMobile ? (
<div id="mobile-board" className="accordion-board">
{MOBILE_KANBAN_ORDER.map((level) => (
{visibleMobileLevels.map((level) => (
<AttentionZone
key={level}
level={level}
@ -492,6 +565,8 @@ function DashboardInner({
onRestore={handleRestore}
collapsed={expandedLevel !== level}
onToggle={handleAccordionToggle}
compactMobile
onPreview={handlePreview}
/>
))}
</div>
@ -515,50 +590,28 @@ function DashboardInner({
{!allProjectsView && !hasAnySessions && <EmptyState />}
{openPRs.length > 0 && (
<div className="mx-auto max-w-[900px]">
<h2 className="mb-3 px-1 text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
Pull Requests
</h2>
<div className="overflow-hidden border border-[var(--color-border-default)]">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-[var(--color-border-muted)]">
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
PR
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Title
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Size
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
CI
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Review
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Unresolved
</th>
</tr>
</thead>
<tbody>
{openPRs.map((pr) => (
<PRTableRow key={pr.number} pr={pr} />
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
{isMobile ? (
<MobileBottomNav
ariaLabel="Dashboard navigation"
activeTab="dashboard"
dashboardHref={dashboardHref}
prsHref={prsHref}
showOrchestrator={!allProjectsView}
orchestratorHref={orchestratorHref}
/>
) : null}
<BottomSheet
session={killTargetSession}
session={sheetState?.session ?? null}
mode={sheetState?.mode ?? "preview"}
onConfirm={handleKillConfirm}
onCancel={() => setKillTargetSession(null)}
onCancel={() => setSheetState(null)}
onRequestKill={handleRequestKillFromPreview}
onMerge={handleMerge}
isMergeReady={
sheetState?.session?.pr ? isPRMergeReady(sheetState.session.pr) : false
}
/>
</>
);
@ -870,16 +923,3 @@ function BoardLegendItem({ label, tone }: { label: string; tone: string }) {
</span>
);
}
function mergeScore(
pr: Pick<DashboardPR, "ciStatus" | "reviewDecision" | "mergeability" | "unresolvedThreads">,
): number {
let score = 0;
if (!pr.mergeability.noConflicts) score += 40;
if (pr.ciStatus === CI_STATUS.FAILING) score += 30;
else if (pr.ciStatus === CI_STATUS.PENDING) score += 5;
if (pr.reviewDecision === "changes_requested") score += 20;
else if (pr.reviewDecision !== "approved") score += 10;
score += pr.unresolvedThreads * 5;
return score;
}

View File

@ -0,0 +1,72 @@
"use client";
export type MobileBottomNavTab = "dashboard" | "prs" | "orchestrator";
interface MobileBottomNavProps {
ariaLabel: string;
activeTab?: MobileBottomNavTab;
dashboardHref: string;
prsHref: string;
showOrchestrator?: boolean;
orchestratorHref?: string | null;
}
export function MobileBottomNav({
ariaLabel,
activeTab,
dashboardHref,
prsHref,
showOrchestrator = true,
orchestratorHref = null,
}: MobileBottomNavProps) {
return (
<nav className="mobile-bottom-nav" aria-label={ariaLabel}>
<a
href={dashboardHref}
className="mobile-bottom-nav__item"
data-active={activeTab === "dashboard" ? "true" : "false"}
aria-current={activeTab === "dashboard" ? "page" : undefined}
>
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 13h8V3H3zm10 8h8V11h-8zM3 21h8v-6H3zm10-10h8V3h-8z" />
</svg>
<span>Dashboard</span>
</a>
<a
href={prsHref}
className="mobile-bottom-nav__item"
data-active={activeTab === "prs" ? "true" : "false"}
aria-current={activeTab === "prs" ? "page" : undefined}
>
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
</svg>
<span>PRs</span>
</a>
{showOrchestrator ? (
orchestratorHref ? (
<a
href={orchestratorHref}
className="mobile-bottom-nav__item"
data-active={activeTab === "orchestrator" ? "true" : "false"}
aria-current={activeTab === "orchestrator" ? "page" : undefined}
>
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 3H5a2 2 0 0 0-2 2v4m16 0V5a2 2 0 0 0-2-2h-4m0 18h4a2 2 0 0 0 2-2v-4M3 15v4a2 2 0 0 0 2 2h4" />
<path d="M9 9h6v6H9z" />
</svg>
<span>Orchestrator</span>
</a>
) : (
<button type="button" className="mobile-bottom-nav__item" disabled>
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 3H5a2 2 0 0 0-2 2v4m16 0V5a2 2 0 0 0-2-2h-4m0 18h4a2 2 0 0 0 2-2v-4M3 15v4a2 2 0 0 0 2 2h4" />
<path d="M9 9h6v6H9z" />
</svg>
<span>Orchestrator</span>
</button>
)
) : null}
</nav>
);
}

View File

@ -128,3 +128,46 @@ export function PRTableRow({ pr }: PRTableRowProps) {
</tr>
);
}
export function PRCard({ pr }: PRTableRowProps) {
const sizeLabel = getSizeLabel(pr.additions, pr.deletions);
const rateLimited = isPRRateLimited(pr);
const reviewLabel = rateLimited
? "stale"
: pr.isDraft
? "draft"
: pr.reviewDecision === "approved"
? "approved"
: pr.reviewDecision === "changes_requested"
? "changes"
: "review";
const ciLabel = rateLimited
? "CI stale"
: pr.ciStatus === "passing"
? "CI passing"
: pr.ciStatus === "failing"
? "CI failing"
: "CI pending";
return (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="mobile-pr-card"
>
<div className="mobile-pr-card__line">
<span className="mobile-pr-card__number">#{pr.number}</span>
<span className="mobile-pr-card__title">{pr.title}</span>
{!rateLimited ? <span className="mobile-pr-card__size">{sizeLabel}</span> : null}
</div>
<div className="mobile-pr-card__meta">
<span>{ciLabel}</span>
<span>{reviewLabel}</span>
<span>{pr.unresolvedThreads} threads</span>
</div>
</a>
);
}

View File

@ -0,0 +1,202 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import {
type DashboardSession,
type DashboardPR,
type DashboardOrchestratorLink,
} from "@/lib/types";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { ProjectSidebar } from "./ProjectSidebar";
import { ThemeToggle } from "./ThemeToggle";
import { DynamicFavicon } from "./DynamicFavicon";
import { PRCard, PRTableRow } from "./PRStatus";
import { MobileBottomNav } from "./MobileBottomNav";
import type { ProjectInfo } from "@/lib/project-name";
interface PullRequestsPageProps {
initialSessions: DashboardSession[];
projectId?: string;
projectName?: string;
projects?: ProjectInfo[];
orchestrators?: DashboardOrchestratorLink[];
}
const EMPTY_ORCHESTRATORS: DashboardOrchestratorLink[] = [];
function getProjectScopedHref(basePath: "/" | "/prs", projectId: string | undefined): string {
if (projectId) {
return `${basePath}?project=${encodeURIComponent(projectId)}`;
}
return `${basePath}?project=all`;
}
export function PullRequestsPage({
initialSessions,
projectId,
projectName,
projects = [],
orchestrators,
}: PullRequestsPageProps) {
const orchestratorLinks = orchestrators ?? EMPTY_ORCHESTRATORS;
const { sessions } = useSessionEvents(initialSessions, null, projectId);
const searchParams = useSearchParams();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const isMobile = useMediaQuery(767);
const showSidebar = projects.length > 1;
const allProjectsView = showSidebar && projectId === undefined;
const currentProjectOrchestrator = useMemo(
() =>
projectId
? orchestratorLinks.find((orchestrator) => orchestrator.projectId === projectId) ?? null
: null,
[orchestratorLinks, projectId],
);
const openPRs = useMemo(() => {
return sessions
.filter(
(session): session is DashboardSession & { pr: DashboardPR } => session.pr?.state === "open",
)
.map((session) => session.pr)
.sort((a, b) => a.number - b.number);
}, [sessions]);
const dashboardHref = getProjectScopedHref("/", projectId);
const prsHref = getProjectScopedHref("/prs", projectId);
const orchestratorHref = currentProjectOrchestrator
? `/sessions/${encodeURIComponent(currentProjectOrchestrator.id)}`
: null;
useEffect(() => {
setMobileMenuOpen(false);
}, [searchParams]);
return (
<div className="dashboard-shell flex h-screen">
{showSidebar ? (
<ProjectSidebar
projects={projects}
sessions={sessions}
activeProjectId={projectId}
activeSessionId={undefined}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
mobileOpen={mobileMenuOpen}
onMobileClose={() => setMobileMenuOpen(false)}
/>
) : null}
<div className="dashboard-main flex-1 overflow-y-auto px-4 py-4 md:px-7 md:py-6">
<DynamicFavicon sessions={sessions} projectName={projectName ? `${projectName} PRs` : "Pull Requests"} />
<section className="dashboard-hero mb-5">
<div className="dashboard-hero__backdrop" />
<div className="dashboard-hero__content">
{showSidebar ? (
<button
type="button"
className="mobile-menu-toggle"
onClick={() => setMobileMenuOpen(true)}
aria-label="Open menu"
>
<svg
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className="h-5 w-5"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
) : null}
<div className="dashboard-hero__primary">
<div className="dashboard-hero__heading">
<div>
<h1 className="dashboard-title">{projectName ? `${projectName} PRs` : "Pull Requests"}</h1>
<p className="dashboard-subtitle">
Review active pull requests without the dashboard board chrome.
</p>
</div>
</div>
<div className="dashboard-stat-cards">
<div className="dashboard-stat-card">
<span className="dashboard-stat-card__value">{openPRs.length}</span>
<span className="dashboard-stat-card__label">Open PRs</span>
<span className="dashboard-stat-card__meta">
{allProjectsView ? "Across all projects" : "In this project"}
</span>
</div>
</div>
</div>
<div className="dashboard-hero__meta">
<div className="flex items-center gap-3">
<ThemeToggle />
</div>
</div>
</div>
</section>
<section className="mx-auto max-w-[900px]">
<h2 className="mb-3 px-1 text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
Pull Requests
</h2>
{openPRs.length === 0 ? (
<div className="border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-4 py-6 text-[12px] text-[var(--color-text-secondary)]">
No open pull requests right now.
</div>
) : isMobile ? (
<div className="mobile-pr-list">
{openPRs.map((pr) => (
<PRCard key={pr.number} pr={pr} />
))}
</div>
) : (
<div className="overflow-hidden border border-[var(--color-border-default)]">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-[var(--color-border-muted)]">
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
PR
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Title
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Size
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
CI
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Review
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Unresolved
</th>
</tr>
</thead>
<tbody>
{openPRs.map((pr) => (
<PRTableRow key={pr.number} pr={pr} />
))}
</tbody>
</table>
</div>
)}
</section>
</div>
{isMobile ? (
<MobileBottomNav
ariaLabel="PR navigation"
activeTab="prs"
dashboardHref={dashboardHref}
prsHref={prsHref}
showOrchestrator={!allProjectsView}
orchestratorHref={orchestratorHref}
/>
) : null}
</div>
);
}

View File

@ -1,12 +1,14 @@
"use client";
import { useState, useEffect, useRef, type ReactNode } from "react";
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { useSearchParams } from "next/navigation";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { type DashboardSession, type DashboardPR, isPRMergeReady } from "@/lib/types";
import { CI_STATUS } from "@composio/ao-core/types";
import { cn } from "@/lib/cn";
import { CICheckList } from "./CIBadge";
import { DirectTerminal } from "./DirectTerminal";
import { MobileBottomNav } from "./MobileBottomNav";
interface OrchestratorZones {
merge: number;
@ -21,6 +23,7 @@ interface SessionDetailProps {
session: DashboardSession;
isOrchestrator?: boolean;
orchestratorZones?: OrchestratorZones;
projectOrchestratorId?: string | null;
}
// ── Helpers ──────────────────────────────────────────────────────────
@ -56,6 +59,18 @@ function buildGitHubBranchUrl(pr: DashboardPR): string {
return `https://github.com/${pr.owner}/${pr.repo}/tree/${pr.branch}`;
}
function activityStateClass(activityLabel: string): string {
const normalized = activityLabel.toLowerCase();
if (normalized === "active") return "session-detail-status-pill--active";
if (normalized === "ready") return "session-detail-status-pill--ready";
if (normalized === "idle") return "session-detail-status-pill--idle";
if (normalized === "waiting for input") return "session-detail-status-pill--waiting";
if (normalized === "blocked" || normalized === "exited") {
return "session-detail-status-pill--error";
}
return "session-detail-status-pill--neutral";
}
function SessionTopStrip({
headline,
activityLabel,
@ -63,6 +78,9 @@ function SessionTopStrip({
branch,
pr,
isOrchestrator = false,
crumbHref,
crumbLabel,
mobileSimple = false,
rightSlot,
}: {
headline: string;
@ -71,13 +89,16 @@ function SessionTopStrip({
branch: string | null;
pr: DashboardPR | null;
isOrchestrator?: boolean;
crumbHref: string;
crumbLabel: string;
mobileSimple?: boolean;
rightSlot?: ReactNode;
}) {
return (
<section className="session-page-header">
<section className={`session-page-header${mobileSimple ? " session-page-header--mobile" : ""}`}>
<div className="session-page-header__crumbs">
<a
href="/"
href={crumbHref}
className="flex items-center gap-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] hover:no-underline"
>
<svg
@ -89,13 +110,17 @@ function SessionTopStrip({
>
<path d="M15 18l-6-6 6-6" />
</svg>
Orchestrator
{crumbLabel}
</a>
<span className="text-[var(--color-border-strong)]">/</span>
<span className="font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{headline}
</span>
{isOrchestrator ? <span className="session-page-header__mode">orchestrator</span> : null}
{!mobileSimple ? (
<span className="font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{headline}
</span>
) : null}
{isOrchestrator && !mobileSimple ? (
<span className="session-page-header__mode">orchestrator</span>
) : null}
</div>
<div className="session-page-header__main">
<div className="session-page-header__identity">
@ -104,13 +129,19 @@ function SessionTopStrip({
</h1>
<div className="session-page-header__meta">
<div
className="flex items-center gap-1.5 border px-2.5 py-1"
className={cn(
"session-detail-status-pill flex items-center gap-1.5 border px-2.5 py-1",
activityStateClass(activityLabel),
)}
style={{
background: `color-mix(in srgb, ${activityColor} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${activityColor} 20%, transparent)`,
}}
>
<span className="h-1.5 w-1.5 shrink-0" style={{ background: activityColor }} />
<span
className="session-detail-status-pill__dot h-1.5 w-1.5 shrink-0"
style={{ background: activityColor }}
/>
<span className="text-[11px] font-semibold" style={{ color: activityColor }}>
{activityLabel}
</span>
@ -121,7 +152,7 @@ function SessionTopStrip({
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="session-detail-link-pill font-[var(--font-mono)] text-[10px] hover:no-underline"
className="session-detail-link-pill session-detail-link-pill--link font-[var(--font-mono)] text-[10px] hover:no-underline"
>
{branch}
</a>
@ -136,7 +167,7 @@ function SessionTopStrip({
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="session-detail-link-pill session-detail-link-pill--accent hover:no-underline"
className="session-detail-link-pill session-detail-link-pill--link session-detail-link-pill--accent hover:no-underline"
>
PR #{pr.number}
</a>
@ -181,6 +212,8 @@ function OrchestratorStatusStrip({
activityColor,
branch,
pr,
crumbHref,
crumbLabel,
}: {
zones: OrchestratorZones;
createdAt: string;
@ -189,6 +222,8 @@ function OrchestratorStatusStrip({
activityColor: string;
branch: string | null;
pr: DashboardPR | null;
crumbHref: string;
crumbLabel: string;
}) {
const [uptime, setUptime] = useState<string>("");
@ -225,6 +260,8 @@ function OrchestratorStatusStrip({
branch={branch}
pr={pr}
isOrchestrator
crumbHref={crumbHref}
crumbLabel={crumbLabel}
rightSlot={
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
<div className="flex items-baseline gap-1.5 mr-2">
@ -282,8 +319,10 @@ export function SessionDetail({
session,
isOrchestrator = false,
orchestratorZones,
projectOrchestratorId = null,
}: SessionDetailProps) {
const searchParams = useSearchParams();
const isMobile = useMediaQuery(767);
const startFullscreen = searchParams.get("fullscreen") === "true";
const pr = session.pr;
const activity = (session.activity && activityMeta[session.activity]) ?? {
@ -305,10 +344,19 @@ export function SessionDetail({
const reloadCommand = opencodeSessionId
? `/exit\nopencode --session ${opencodeSessionId}\n`
: undefined;
const dashboardHref = session.projectId ? `/?project=${encodeURIComponent(session.projectId)}` : "/";
const prsHref = session.projectId ? `/prs?project=${encodeURIComponent(session.projectId)}` : "/prs";
const crumbHref = dashboardHref;
const crumbLabel = isOrchestrator ? "Orchestrator" : "Dashboard";
const orchestratorHref = useMemo(() => {
if (isOrchestrator) return `/sessions/${encodeURIComponent(session.id)}`;
if (!projectOrchestratorId) return null;
return `/sessions/${encodeURIComponent(projectOrchestratorId)}`;
}, [isOrchestrator, projectOrchestratorId, session.id]);
return (
<div className="session-detail-page min-h-screen bg-[var(--color-bg-base)]">
{isOrchestrator && orchestratorZones && (
{isOrchestrator && orchestratorZones && !isMobile && (
<OrchestratorStatusStrip
zones={orchestratorZones}
createdAt={session.createdAt}
@ -317,22 +365,29 @@ export function SessionDetail({
activityColor={activity.color}
branch={session.branch}
pr={pr}
crumbHref={crumbHref}
crumbLabel={crumbLabel}
/>
)}
<div className="mx-auto max-w-[1180px] px-5 py-5 lg:px-8">
<main className="min-w-0">
{!isOrchestrator && (
{(!isOrchestrator || isMobile) && (
<SessionTopStrip
headline={headline}
activityLabel={activity.label}
activityColor={activity.color}
branch={session.branch}
pr={pr}
isOrchestrator={isOrchestrator}
crumbHref={crumbHref}
crumbLabel={crumbLabel}
mobileSimple={isMobile}
/>
)}
<section className="mt-5">
<div id="session-terminal-section" aria-hidden="true" />
<div className="mb-3 flex items-center gap-2">
<div
className="h-3 w-0.5"
@ -353,12 +408,19 @@ export function SessionDetail({
</section>
{pr ? (
<section className="mt-6">
<section id="session-pr-section" className="mt-6">
<PRCard pr={pr} sessionId={session.id} />
</section>
) : null}
</main>
</div>
<MobileBottomNav
ariaLabel="Session navigation"
activeTab={isOrchestrator ? "orchestrator" : undefined}
dashboardHref={dashboardHref}
prsHref={prsHref}
orchestratorHref={orchestratorHref}
/>
</div>
);
}

View File

@ -16,7 +16,7 @@ export function ThemeToggle() {
return (
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="flex h-9 w-9 items-center justify-center rounded-[8px] border border-[var(--color-border-strong)] bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-bg-elevated-hover)]"
className="flex h-9 w-9 items-center justify-center border border-[var(--color-border-strong)] bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-bg-elevated-hover)]"
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
title={`Switch to ${isDark ? "light" : "dark"} mode`}
>

View File

@ -0,0 +1,217 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Dashboard } from "../Dashboard";
import { makePR, makeSession } from "../../__tests__/helpers";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
function mockMobileViewport() {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: query.includes("max-width: 767px"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
describe("Dashboard mobile layout", () => {
beforeEach(() => {
mockMobileViewport();
Element.prototype.scrollIntoView = vi.fn();
const eventSourceMock = {
onmessage: null,
onerror: null,
onopen: null,
close: vi.fn(),
};
const eventSourceConstructor = vi.fn(() => eventSourceMock as unknown as EventSource);
global.EventSource = Object.assign(eventSourceConstructor, {
CONNECTING: 0,
OPEN: 1,
CLOSED: 2,
}) as unknown as typeof EventSource;
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
text: () => Promise.resolve(""),
} as Response),
);
});
it("caps mobile sections to five rows until view-all is tapped", () => {
const sessions = Array.from({ length: 6 }, (_, index) =>
makeSession({
id: `needs-input-${index + 1}`,
summary: `Need approval ${index + 1}`,
status: "needs_input",
activity: "waiting_input",
}),
);
render(<Dashboard initialSessions={sessions} />);
expect(screen.getByText("Need approval 1")).toBeInTheDocument();
expect(screen.getByText("Need approval 5")).toBeInTheDocument();
expect(screen.queryByText("Need approval 6")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /view all 6/i }));
expect(screen.getByText("Need approval 6")).toBeInTheDocument();
});
it("opens a preview sheet from a mobile row and keeps prompting out of the dashboard", () => {
const session = makeSession({
id: "respond-1",
status: "needs_input",
activity: "waiting_input",
summary: "Need approval to proceed",
branch: "feat/mobile-density",
issueLabel: "#557",
});
render(<Dashboard initialSessions={[session]} />);
expect(screen.getByRole("link", { name: /go to need approval to proceed/i })).toHaveAttribute(
"href",
"/sessions/respond-1",
);
fireEvent.click(screen.getByRole("button", { name: /open need approval to proceed/i }));
expect(screen.getByRole("link", { name: "Open session" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Terminate" })).toBeInTheDocument();
expect(screen.getAllByText("Need approval to proceed")).toHaveLength(3);
expect(screen.getAllByText("respond").length).toBeGreaterThan(0);
expect(screen.getAllByText("needs input").length).toBeGreaterThan(0);
expect(screen.getByText("waiting input")).toBeInTheDocument();
expect(screen.getByText("feat/mobile-density")).toBeInTheDocument();
expect(screen.getByText("#557")).toBeInTheDocument();
expect(screen.queryByPlaceholderText("Type a reply...")).not.toBeInTheDocument();
});
it("does not render embedded PR cards on the dashboard anymore", () => {
const sessions = [
makeSession({
id: "merge-1",
status: "approved",
pr: makePR({ number: 87, title: "Add login flow" }),
}),
];
render(<Dashboard initialSessions={sessions} />);
expect(screen.queryByRole("link", { name: /#87 add login flow/i })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute("href", "/prs?project=all");
});
it("renders the mobile bottom nav with dashboard, PRs, and orchestrator", () => {
render(
<Dashboard
initialSessions={[makeSession()]}
projectId="my-app"
orchestrators={[
{ id: "my-app-orchestrator", projectId: "my-app", projectName: "My App" },
]}
/>,
);
expect(screen.getByRole("navigation", { name: /dashboard navigation/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute("href", "/prs?project=my-app");
expect(screen.getByRole("link", { name: "Orchestrator" })).toHaveAttribute(
"href",
"/sessions/my-app-orchestrator",
);
});
it("hides orchestrator nav item in all-projects view", () => {
render(
<Dashboard
initialSessions={[makeSession()]}
projects={[{ id: "my-app", name: "My App" }, { id: "docs", name: "Docs" }]}
/>,
);
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute("href", "/?project=all");
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute("href", "/prs?project=all");
expect(screen.queryByRole("link", { name: "Orchestrator" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Orchestrator" })).not.toBeInTheDocument();
});
it("routes the PR nav item to the dedicated PR page", () => {
render(
<Dashboard
initialSessions={[
makeSession({
id: "merge-2",
status: "approved",
pr: makePR({ number: 91, title: "Polish mobile nav" }),
}),
]}
projectId="my-app"
/>,
);
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute(
"href",
"/prs?project=my-app",
);
});
it("filters the mobile board by selected attention bucket", () => {
render(
<Dashboard
initialSessions={[
makeSession({
id: "respond-1",
status: "needs_input",
activity: "waiting_input",
summary: "Need approval to proceed",
}),
makeSession({
id: "working-1",
status: "running",
activity: "active",
summary: "Implement dashboard filters",
}),
]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Working" }));
expect(screen.getByText("Implement dashboard filters")).toBeInTheDocument();
expect(screen.queryByText("Need approval to proceed")).not.toBeInTheDocument();
});
it("shows a stable empty state when an expanded mobile section has no sessions", () => {
render(
<Dashboard
initialSessions={[
makeSession({
id: "respond-1",
status: "needs_input",
activity: "waiting_input",
summary: "Need approval to proceed",
}),
]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Ready" }));
expect(screen.getByText("No sessions")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,95 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PullRequestsPage } from "../PullRequestsPage";
import { makePR, makeSession } from "../../__tests__/helpers";
const mockedUseSearchParams = vi.fn(() => new URLSearchParams());
vi.mock("next/navigation", () => ({
useSearchParams: () => mockedUseSearchParams(),
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/prs",
}));
function mockMobileViewport() {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: query.includes("max-width: 767px"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
describe("PullRequestsPage", () => {
beforeEach(() => {
mockMobileViewport();
const eventSourceMock = {
onmessage: null,
onerror: null,
onopen: null,
close: vi.fn(),
};
const eventSourceConstructor = vi.fn(() => eventSourceMock as unknown as EventSource);
global.EventSource = Object.assign(eventSourceConstructor, {
CONNECTING: 0,
OPEN: 1,
CLOSED: 2,
}) as unknown as typeof EventSource;
});
it("renders PR cards and keeps the PR tab active on mobile", () => {
render(
<PullRequestsPage
initialSessions={[
makeSession({
id: "merge-1",
projectId: "my-app",
status: "approved",
pr: makePR({ number: 634, title: "Mobile dashboard density pass" }),
}),
]}
projectId="my-app"
projectName="My App"
orchestrators={[{ id: "my-app-orchestrator", projectId: "my-app", projectName: "My App" }]}
/>,
);
expect(screen.getByRole("link", { name: /#634 mobile dashboard density pass/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute("href", "/?project=my-app");
expect(screen.getByRole("link", { name: "Orchestrator" })).toHaveAttribute(
"href",
"/sessions/my-app-orchestrator",
);
});
it("preserves the all-projects scope in mobile bottom nav links", () => {
render(
<PullRequestsPage
initialSessions={[
makeSession({
id: "merge-1",
projectId: "my-app",
status: "approved",
pr: makePR({ number: 634, title: "Mobile dashboard density pass" }),
}),
]}
projectName="All Projects"
projects={[
{ id: "my-app", name: "My App", path: "/tmp/my-app" },
{ id: "docs", name: "Docs", path: "/tmp/docs" },
]}
/>,
);
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute("href", "/?project=all");
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute("href", "/prs?project=all");
});
});

View File

@ -0,0 +1,157 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionDetail } from "../SessionDetail";
import { makePR, makeSession } from "../../__tests__/helpers";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("../DirectTerminal", () => ({
DirectTerminal: ({ sessionId }: { sessionId: string }) => (
<div data-testid="direct-terminal">{sessionId}</div>
),
}));
function mockMobileViewport() {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: query.includes("max-width: 767px"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
describe("SessionDetail mobile navbar", () => {
beforeEach(() => {
mockMobileViewport();
});
it("shows dashboard, PRs, and orchestrator nav on orchestrator pages", () => {
const session = makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
metadata: { role: "orchestrator" },
summary: "Orchestrator session title",
});
render(
<SessionDetail
session={session}
isOrchestrator
orchestratorZones={{ merge: 1, respond: 0, review: 0, pending: 0, working: 2, done: 0 }}
projectOrchestratorId="my-app-orchestrator"
/>,
);
const nav = screen.getByRole("navigation", { name: /session navigation/i });
expect(nav).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute("href", "/?project=my-app");
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute("href", "/prs?project=my-app");
expect(screen.getAllByRole("link", { name: "Orchestrator" }).at(-1)).toHaveAttribute(
"aria-current",
"page",
);
expect(screen.getAllByText("Orchestrator session title")).toHaveLength(1);
expect(screen.queryByText("agents")).not.toBeInTheDocument();
expect(screen.queryByText("responding")).not.toBeInTheDocument();
});
it("routes PRs to the dedicated page from worker session pages", () => {
render(
<SessionDetail
session={makeSession({
id: "worker-1",
projectId: "my-app",
pr: makePR({ number: 55, title: "Fix mobile navbar" }),
})}
projectOrchestratorId="my-app-orchestrator"
/>,
);
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute("href", "/prs?project=my-app");
expect(screen.getAllByRole("link", { name: "Orchestrator" }).at(-1)).toHaveAttribute(
"href",
"/sessions/my-app-orchestrator",
);
});
it("keeps branch and PR chips in the compact mobile header", () => {
render(
<SessionDetail
session={makeSession({
id: "worker-2",
projectId: "my-app",
summary: "Compact mobile header",
branch: "feat/compact-header",
pr: makePR({ number: 77, title: "Compact header polish" }),
})}
projectOrchestratorId="my-app-orchestrator"
/>,
);
expect(screen.getByText("Compact mobile header")).toBeInTheDocument();
expect(screen.getByText("feat/compact-header")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "PR #77" })).toHaveClass(
"session-detail-link-pill--link",
);
expect(screen.getByRole("link", { name: "feat/compact-header" })).toHaveClass(
"session-detail-link-pill--link",
);
});
it("preserves CI and unresolved review comment detail on mobile session pages", () => {
render(
<SessionDetail
session={makeSession({
id: "worker-3",
projectId: "my-app",
summary: "Review heavy session",
pr: makePR({
number: 88,
title: "Keep PR detail intact",
ciStatus: "failing",
ciChecks: [
{ name: "build", status: "failed", url: "https://ci.example/build" },
{ name: "lint", status: "passed", url: "https://ci.example/lint" },
],
reviewDecision: "changes_requested",
mergeability: {
mergeable: false,
ciPassing: false,
approved: false,
noConflicts: true,
blockers: ["CI failing", "Changes requested"],
},
unresolvedThreads: 2,
unresolvedComments: [
{
url: "https://github.com/acme/app/pull/88#discussion_r1",
path: "src/app.ts",
author: "bugbot",
body: "### Fix null handling\n<!-- DESCRIPTION START -->Handle missing data safely<!-- DESCRIPTION END -->",
},
],
}),
})}
projectOrchestratorId="my-app-orchestrator"
/>,
);
expect(screen.getByText(/CI failing/i)).toBeInTheDocument();
expect(screen.getByText(/Changes requested/i)).toBeInTheDocument();
expect(screen.getByText(/2 unresolved comments/i)).toBeInTheDocument();
expect(screen.getByText("Unresolved Comments")).toBeInTheDocument();
expect(screen.getByText("Fix null handling")).toBeInTheDocument();
expect(screen.getByText("build")).toBeInTheDocument();
expect(screen.getByText("lint")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ask Agent to Fix" })).toBeInTheDocument();
});
});

View File

@ -126,4 +126,25 @@ describe("useMediaQuery", () => {
.calls[0][1];
expect(addedListener).toBe(removedListener);
});
it("falls back to addListener/removeListener when addEventListener is unavailable", async () => {
currentMQL = {
...makeMQL(false),
addEventListener: undefined,
removeEventListener: undefined,
} as unknown as ReturnType<typeof makeMQL> & { _fire: (m: boolean) => void };
const { unmount } = renderHook(() => useMediaQuery("(max-width: 767px)"));
await act(async () => {});
expect(currentMQL.addListener).toHaveBeenCalledWith(expect.any(Function));
unmount();
expect(currentMQL.removeListener).toHaveBeenCalledWith(expect.any(Function));
const addedListener = (currentMQL.addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
const removedListener = (currentMQL.removeListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(addedListener).toBe(removedListener);
});
});

View File

@ -7,7 +7,9 @@ import { makeSession } from "../../__tests__/helpers";
describe("useSessionEvents", () => {
let eventSourceMock: {
onmessage: ((event: MessageEvent) => void) | null;
onopen: (() => void) | null;
onerror: (() => void) | null;
readyState: number;
close: () => void;
};
let eventSourceInstances: (typeof eventSourceMock)[];
@ -17,7 +19,9 @@ describe("useSessionEvents", () => {
const eventSourceConstructor = vi.fn(() => {
const instance = {
onmessage: null as ((event: MessageEvent) => void) | null,
onopen: null as (() => void) | null,
onerror: null as (() => void) | null,
readyState: 0,
close: vi.fn(),
};
eventSourceInstances.push(instance);
@ -371,6 +375,46 @@ describe("useSessionEvents", () => {
});
describe("session state updates", () => {
it("falls back to disconnected after a prolonged EventSource outage", async () => {
vi.useFakeTimers();
const sessions = makeSessions(1);
const { result } = renderHook(() => useSessionEvents(sessions, null));
expect(result.current.connectionStatus).toBe("connected");
await act(async () => {
eventSourceMock.readyState = EventSource.CONNECTING;
eventSourceMock.onerror?.();
});
expect(result.current.connectionStatus).toBe("reconnecting");
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
});
expect(result.current.connectionStatus).toBe("disconnected");
});
it("clears the disconnect timer when EventSource reconnects", async () => {
vi.useFakeTimers();
const sessions = makeSessions(1);
const { result } = renderHook(() => useSessionEvents(sessions, null));
await act(async () => {
eventSourceMock.readyState = EventSource.CONNECTING;
eventSourceMock.onerror?.();
await vi.advanceTimersByTimeAsync(2000);
eventSourceMock.readyState = EventSource.OPEN;
eventSourceMock.onopen?.();
await vi.advanceTimersByTimeAsync(3000);
});
expect(result.current.connectionStatus).toBe("connected");
});
it("applies snapshot patches to sessions", async () => {
const sessions = makeSessions(2);

View File

@ -44,10 +44,18 @@ export function useMediaQuery(queryOrBreakpoint: string | number): boolean {
setMatches(event.matches);
};
mediaQueryList.addEventListener("change", listener);
if (typeof mediaQueryList.addEventListener === "function") {
mediaQueryList.addEventListener("change", listener);
return () => {
mediaQueryList.removeEventListener("change", listener);
};
}
mediaQueryList.addListener(listener);
return () => {
mediaQueryList.removeEventListener("change", listener);
mediaQueryList.removeListener(listener);
};
}, [query]);

View File

@ -5,6 +5,7 @@ import type { DashboardSession, GlobalPauseState, SSESnapshotEvent } from "@/lib
const MEMBERSHIP_REFRESH_DELAY_MS = 120;
const STALE_REFRESH_INTERVAL_MS = 15000;
const DISCONNECTED_GRACE_PERIOD_MS = 4000;
type ConnectionStatus = "connected" | "reconnecting" | "disconnected";
@ -75,6 +76,7 @@ export function useSessionEvents(
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingMembershipKeyRef = useRef<string | null>(null);
const lastRefreshAtRef = useRef(Date.now());
const disconnectedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
sessionsRef.current = state.sessions;
@ -97,6 +99,13 @@ export function useSessionEvents(
}
};
const clearDisconnectedTimer = () => {
if (disconnectedTimerRef.current) {
clearTimeout(disconnectedTimerRef.current);
disconnectedTimerRef.current = null;
}
};
const scheduleRefresh = () => {
if (disposed) return;
if (refreshingRef.current || refreshTimerRef.current) return;
@ -177,15 +186,28 @@ export function useSessionEvents(
};
es.onopen = () => {
clearDisconnectedTimer();
if (!disposed) dispatch({ type: "setConnection", status: "connected" });
};
es.onerror = () => {
if (!disposed) {
dispatch({
type: "setConnection",
status: es.readyState === EventSource.CLOSED ? "disconnected" : "reconnecting",
});
if (disposed) return;
if (es.readyState === EventSource.CLOSED) {
clearDisconnectedTimer();
dispatch({ type: "setConnection", status: "disconnected" });
return;
}
dispatch({ type: "setConnection", status: "reconnecting" });
if (disconnectedTimerRef.current === null) {
disconnectedTimerRef.current = setTimeout(() => {
disconnectedTimerRef.current = null;
if (!disposed && es.readyState !== EventSource.OPEN) {
dispatch({ type: "setConnection", status: "disconnected" });
}
}, DISCONNECTED_GRACE_PERIOD_MS);
}
};
@ -196,6 +218,7 @@ export function useSessionEvents(
refreshingRef.current = false;
pendingMembershipKeyRef.current = null;
clearRefreshTimer();
clearDisconnectedTimer();
es.close();
};
}, [project]);

View File

@ -0,0 +1,123 @@
import type { DashboardSession, DashboardOrchestratorLink } from "@/lib/types";
import { getServices, getSCM } from "@/lib/services";
import {
sessionToDashboard,
resolveProject,
enrichSessionPR,
enrichSessionsMetadata,
listDashboardOrchestrators,
} from "@/lib/serialize";
import { prCache, prCacheKey } from "@/lib/cache";
import { getPrimaryProjectId, getProjectName, getAllProjects, type ProjectInfo } from "@/lib/project-name";
import { filterProjectSessions, filterWorkerSessions } from "@/lib/project-utils";
import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause";
interface DashboardPageData {
sessions: DashboardSession[];
globalPause: GlobalPauseState | null;
orchestrators: DashboardOrchestratorLink[];
projectName: string;
projects: ProjectInfo[];
selectedProjectId?: string;
}
function getSelectedProjectName(projectFilter: string | undefined): string {
if (projectFilter === "all") return "All Projects";
const projects = getAllProjects();
if (projectFilter) {
const selectedProject = projects.find((project) => project.id === projectFilter);
if (selectedProject) return selectedProject.name;
}
return getProjectName();
}
export function resolveDashboardProjectFilter(project?: string): string {
return project ?? getPrimaryProjectId();
}
export async function getDashboardPageData(project?: string): Promise<DashboardPageData> {
const projectFilter = resolveDashboardProjectFilter(project);
const pageData: DashboardPageData = {
sessions: [],
globalPause: null,
orchestrators: [],
projectName: getSelectedProjectName(projectFilter),
projects: getAllProjects(),
selectedProjectId: projectFilter === "all" ? undefined : projectFilter,
};
try {
const { config, registry, sessionManager } = await getServices();
const allSessions = await sessionManager.list();
pageData.globalPause = resolveGlobalPause(allSessions);
const visibleSessions = filterProjectSessions(allSessions, projectFilter, config.projects);
pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects);
const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects);
pageData.sessions = coreSessions.map(sessionToDashboard);
const metaTimeout = new Promise<void>((resolve) => setTimeout(resolve, 3_000));
await Promise.race([
enrichSessionsMetadata(coreSessions, pageData.sessions, config, registry),
metaTimeout,
]);
const terminalStatuses = new Set(["merged", "killed", "cleanup", "done", "terminated"]);
const enrichPromises = coreSessions.map((core, index) => {
if (!core.pr) return Promise.resolve();
const cacheKey = prCacheKey(core.pr.owner, core.pr.repo, core.pr.number);
const cached = prCache.get(cacheKey);
if (cached) {
if (pageData.sessions[index].pr) {
pageData.sessions[index].pr.state = cached.state;
pageData.sessions[index].pr.title = cached.title;
pageData.sessions[index].pr.additions = cached.additions;
pageData.sessions[index].pr.deletions = cached.deletions;
pageData.sessions[index].pr.ciStatus = cached.ciStatus as
| "none"
| "pending"
| "passing"
| "failing";
pageData.sessions[index].pr.reviewDecision = cached.reviewDecision as
| "none"
| "pending"
| "approved"
| "changes_requested";
pageData.sessions[index].pr.ciChecks = cached.ciChecks.map((check) => ({
name: check.name,
status: check.status as "pending" | "running" | "passed" | "failed" | "skipped",
url: check.url,
}));
pageData.sessions[index].pr.mergeability = cached.mergeability;
pageData.sessions[index].pr.unresolvedThreads = cached.unresolvedThreads;
pageData.sessions[index].pr.unresolvedComments = cached.unresolvedComments;
}
if (
terminalStatuses.has(core.status) ||
cached.state === "merged" ||
cached.state === "closed"
) {
return Promise.resolve();
}
}
const projectConfig = resolveProject(core, config.projects);
const scm = getSCM(registry, projectConfig);
if (!scm) return Promise.resolve();
return enrichSessionPR(pageData.sessions[index], scm, core.pr);
});
const enrichTimeout = new Promise<void>((resolve) => setTimeout(resolve, 4_000));
await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]);
} catch {
pageData.sessions = [];
pageData.globalPause = null;
pageData.orchestrators = [];
}
return pageData;
}