Fix mobile dashboard navigation and connection state handling
This commit is contained in:
parent
fc9edb8afb
commit
b8eab07d28
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue