diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 1699c7ea3..3dbca60d1 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -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 { diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 24281b4e5..8b860d3c5 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -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 { 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((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((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 ( diff --git a/packages/web/src/app/prs/page.tsx b/packages/web/src/app/prs/page.tsx new file mode 100644 index 000000000..a89b53699 --- /dev/null +++ b/packages/web/src/app/prs/page.tsx @@ -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 { + 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 ( + + ); +} diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index fb855b564..d051f274e 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -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(null); const [zoneCounts, setZoneCounts] = useState(null); + const [projectOrchestratorId, setProjectOrchestratorId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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} /> ); } diff --git a/packages/web/src/components/AttentionZone.tsx b/packages/web/src/components/AttentionZone.tsx index 9c7b35877..d643114b4 100644 --- a/packages/web/src/components/AttentionZone.tsx +++ b/packages/web/src/components/AttentionZone.tsx @@ -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({
- {sessions.length > 0 && ( -
- {sessions.map((session) => ( - - ))} + {sessions.length > 0 ? ( +
+ {visibleSessions.map((session) => + compactMobile ? ( + + ) : ( + + ), + )} + {compactMobile && hiddenCount > 0 ? ( + + ) : null}
- )} + ) : compactMobile ? ( +
+
No sessions
+
+ ) : null}
); @@ -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 ( +
+ + +
+ ); +} + +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 {label}; +} diff --git a/packages/web/src/components/BottomSheet.tsx b/packages/web/src/components/BottomSheet.tsx index e2e06d508..25b68924e 100644 --- a/packages/web/src/components/BottomSheet.tsx +++ b/packages/web/src/components/BottomSheet.tsx @@ -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(null); const sheetRef = useRef(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)

- Terminate session? + {mode === "confirm-kill" ? "Terminate session?" : title}

-

This action cannot be undone.

+

+ {mode === "confirm-kill" + ? "This action cannot be undone." + : `${attention} · started ${getRelativeTime(session.createdAt)}`} +

-
{title}
+ {mode === "confirm-kill" ? ( +
{title}
+ ) : null}
- {branch !== "—" && ( - {branch} - )} - Started {runtime} + {tags.map((tag) => ( + + {tag.label} + + ))}
+ {summary ?

{summary}

: null}
- - + {mode === "confirm-kill" ? ( + <> + + + + ) : ( + <> + + Open session + + {isMergeReady && session.pr && onMerge ? ( + + ) : hasLiveTerminateAction && onRequestKill ? ( + + ) : null} + + )}
diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index fbafa0684..2d8dd1879 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -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(null); + const [expandedLevel, setExpandedLevel] = useState(null); + const [mobileFilter, setMobileFilter] = useState("all"); const showSidebar = projects.length > 1; const { showToast } = useToast(); - const [killTargetSession, setKillTargetSession] = useState(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(); 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({ /> )}
+
+ {isMobile ? ( + + ) : null} 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 }) { ); } - -function mergeScore( - pr: Pick, -): 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; -} diff --git a/packages/web/src/components/MobileBottomNav.tsx b/packages/web/src/components/MobileBottomNav.tsx new file mode 100644 index 000000000..c5338b775 --- /dev/null +++ b/packages/web/src/components/MobileBottomNav.tsx @@ -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 ( + + ); +} diff --git a/packages/web/src/components/PRStatus.tsx b/packages/web/src/components/PRStatus.tsx index 1ead53803..bbe33c792 100644 --- a/packages/web/src/components/PRStatus.tsx +++ b/packages/web/src/components/PRStatus.tsx @@ -128,3 +128,46 @@ export function PRTableRow({ pr }: PRTableRowProps) { ); } + +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 ( + +
+ #{pr.number} + {pr.title} + {!rateLimited ? {sizeLabel} : null} +
+
+ {ciLabel} + {reviewLabel} + {pr.unresolvedThreads} threads +
+
+ ); +} diff --git a/packages/web/src/components/PullRequestsPage.tsx b/packages/web/src/components/PullRequestsPage.tsx new file mode 100644 index 000000000..7793b1d0a --- /dev/null +++ b/packages/web/src/components/PullRequestsPage.tsx @@ -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 ( +
+ {showSidebar ? ( + setSidebarCollapsed((current) => !current)} + mobileOpen={mobileMenuOpen} + onMobileClose={() => setMobileMenuOpen(false)} + /> + ) : null} +
+ +
+
+
+ {showSidebar ? ( + + ) : null} +
+
+
+

{projectName ? `${projectName} PRs` : "Pull Requests"}

+

+ Review active pull requests without the dashboard board chrome. +

+
+
+
+
+ {openPRs.length} + Open PRs + + {allProjectsView ? "Across all projects" : "In this project"} + +
+
+
+ +
+
+ +
+
+
+
+ +
+

+ Pull Requests +

+ {openPRs.length === 0 ? ( +
+ No open pull requests right now. +
+ ) : isMobile ? ( +
+ {openPRs.map((pr) => ( + + ))} +
+ ) : ( +
+ + + + + + + + + + + + + {openPRs.map((pr) => ( + + ))} + +
+ PR + + Title + + Size + + CI + + Review + + Unresolved +
+
+ )} +
+
+ {isMobile ? ( + + ) : null} +
+ ); +} diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index b3d5aa377..6414d3a1a 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -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 ( -
+
- Orchestrator + {crumbLabel} / - - {headline} - - {isOrchestrator ? orchestrator : null} + {!mobileSimple ? ( + + {headline} + + ) : null} + {isOrchestrator && !mobileSimple ? ( + orchestrator + ) : null}
@@ -104,13 +129,19 @@ function SessionTopStrip({
- + {activityLabel} @@ -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} @@ -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} @@ -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(""); @@ -225,6 +260,8 @@ function OrchestratorStatusStrip({ branch={branch} pr={pr} isOrchestrator + crumbHref={crumbHref} + crumbLabel={crumbLabel} rightSlot={
@@ -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 (
- {isOrchestrator && orchestratorZones && ( + {isOrchestrator && orchestratorZones && !isMobile && ( )}
- {!isOrchestrator && ( + {(!isOrchestrator || isMobile) && ( )}
+
+
); } diff --git a/packages/web/src/components/ThemeToggle.tsx b/packages/web/src/components/ThemeToggle.tsx index 329be6996..c1542d0a4 100644 --- a/packages/web/src/components/ThemeToggle.tsx +++ b/packages/web/src/components/ThemeToggle.tsx @@ -16,7 +16,7 @@ export function ThemeToggle() { return (