merge: dev into main
2
.github/badges/version.svg
vendored
|
|
@ -1 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="103" height="20" role="img" aria-label="version: v2.1.1"><title>version: v2.1.1</title><g shape-rendering="crispEdges"><rect width="51" height="20" fill="#555"/><rect x="51" width="52" height="20" fill="#007ec6"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"><text x="25" y="14">version</text><text x="77" y="14">v2.1.1</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="103" height="20" role="img" aria-label="version: v2.1.2"><title>version: v2.1.2</title><g shape-rendering="crispEdges"><rect width="51" height="20" fill="#555"/><rect x="51" width="52" height="20" fill="#007ec6"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"><text x="25" y="14">version</text><text x="77" y="14">v2.1.2</text></g></svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 445 B After Width: | Height: | Size: 445 B |
39
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directories:
|
||||
- /
|
||||
- /landing
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
cooldown:
|
||||
default-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore
|
||||
prefix-development: chore
|
||||
include: scope
|
||||
groups:
|
||||
npm-minor-and-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: tuesday
|
||||
time: "09:00"
|
||||
timezone: Etc/UTC
|
||||
cooldown:
|
||||
default-days: 3
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: chore
|
||||
include: scope
|
||||
13
.github/workflows/ci.yml
vendored
|
|
@ -14,6 +14,7 @@ on:
|
|||
- 'runtime.lock.json'
|
||||
- 'test/**'
|
||||
- '.github/workflows/**'
|
||||
- '.github/dependabot.yml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
|
@ -34,6 +35,7 @@ on:
|
|||
- 'runtime.lock.json'
|
||||
- 'test/**'
|
||||
- '.github/workflows/**'
|
||||
- '.github/dependabot.yml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
|
@ -66,11 +68,16 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Restore pnpm node-gyp executable bit
|
||||
run: find "$(dirname "$(command -v pnpm)")/store" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
run: |
|
||||
PNPM_STORE="$(pnpm store path)"
|
||||
find "$PNPM_STORE" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Audit dependencies
|
||||
run: pnpm audit --audit-level high
|
||||
|
||||
- name: Restore ESLint cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
|
|
@ -99,7 +106,9 @@ jobs:
|
|||
cache: pnpm
|
||||
|
||||
- name: Restore pnpm node-gyp executable bit
|
||||
run: find "$(dirname "$(command -v pnpm)")/store" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
run: |
|
||||
PNPM_STORE="$(pnpm store path)"
|
||||
find "$PNPM_STORE" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
|
|
|||
4
.github/workflows/landing.yml
vendored
|
|
@ -31,6 +31,10 @@ jobs:
|
|||
working-directory: landing
|
||||
run: npm ci
|
||||
|
||||
- name: Audit dependencies
|
||||
working-directory: landing
|
||||
run: npm audit --audit-level high
|
||||
|
||||
- name: Generate static site
|
||||
working-directory: landing
|
||||
env:
|
||||
|
|
|
|||
22
.github/workflows/release.yml
vendored
|
|
@ -45,6 +45,12 @@ jobs:
|
|||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Restore pnpm node-gyp executable bit
|
||||
shell: bash
|
||||
run: |
|
||||
PNPM_STORE="$(pnpm store path)"
|
||||
find "$PNPM_STORE" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -336,6 +342,11 @@ jobs:
|
|||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Restore pnpm node-gyp executable bit
|
||||
run: |
|
||||
PNPM_STORE="$(pnpm store path)"
|
||||
find "$PNPM_STORE" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -452,6 +463,12 @@ jobs:
|
|||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Restore pnpm node-gyp executable bit
|
||||
shell: bash
|
||||
run: |
|
||||
PNPM_STORE="$(pnpm store path)"
|
||||
find "$PNPM_STORE" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -573,6 +590,11 @@ jobs:
|
|||
sudo apt-get update
|
||||
sudo apt-get install -y libarchive-tools rpm xvfb
|
||||
|
||||
- name: Restore pnpm node-gyp executable bit
|
||||
run: |
|
||||
PNPM_STORE="$(pnpm store path)"
|
||||
find "$PNPM_STORE" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ An orchestration layer for AI agent teams across Claude, Codex, and OpenCode.
|
|||
|
||||
- **Built-in code editor** — edit project files with Git support without leaving the app
|
||||
|
||||
- **Branch strategy** — choose via prompt: single branch or git worktree per agent
|
||||
- **Branch strategy** - choose per teammate at launch: use the main checkout or run selected agents in their own git worktree. You can still spell out branch rules in the provisioning prompt.
|
||||
|
||||
- **Team member stats** — global performance statistics per member
|
||||
|
||||
|
|
@ -252,13 +252,13 @@ Yes. Every task shows a full diff view where you can accept, reject, or comment
|
|||
<details>
|
||||
<summary><strong>What happens if an agent gets stuck?</strong></summary>
|
||||
<br />
|
||||
Send a direct message to course-correct, or stop and restart from the process dashboard. If an agent needs your input, you'll get a notification and the task will show a distinct badge on the board.
|
||||
Send a direct message to course-correct, or stop and restart from the process dashboard. Agent Teams also has a nudge system: the app can send a short control message when there is a clear reason to wake an agent up, such as after a known rate-limit cooldown, when a teammate has not synced with its current task or review, or when progress appears stalled. Nudges are guarded and rate limited, so they are meant to help the agent continue, not spam it. If an agent needs your input, you'll get a notification and the task will show a distinct badge on the board.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Does it support multiple projects and teams?</strong></summary>
|
||||
<br />
|
||||
Yes. Run multiple teams in one project or across different projects, even simultaneously. To avoid Git conflicts, ask agents to use git worktree in your provisioning prompt.
|
||||
Yes. Run multiple teams in one project or across different projects, even simultaneously. To avoid Git conflicts, enable git worktree isolation for selected teammates when launching the team, and use the provisioning prompt for any extra branch or merge rules.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1819,10 +1819,6 @@
|
|||
scale(var(--agent-tablet-scale));
|
||||
}
|
||||
|
||||
.cyber-agent__card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cyber-feature-rail-shell {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -1864,6 +1860,88 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1100px) {
|
||||
.cyber-agent[data-agent="planner"] {
|
||||
transform:
|
||||
translate3d(-50%, -100%, 0)
|
||||
scale(var(--agent-tablet-scale));
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="planner"] .cyber-agent__float {
|
||||
top: 4px;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="planner"] .cyber-agent__image {
|
||||
transform:
|
||||
scaleX(var(--agent-face))
|
||||
rotate(var(--agent-lean));
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="planner"] .cyber-agent__contact {
|
||||
left: 17%;
|
||||
right: 17%;
|
||||
bottom: 18px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.cyber-agent__card {
|
||||
display: block;
|
||||
width: 138px;
|
||||
padding: 8px 9px;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.24;
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="planner"] .cyber-agent__card,
|
||||
.cyber-agent[data-agent="lead"] .cyber-agent__card,
|
||||
.cyber-agent[data-agent="developer"] .cyber-agent__card {
|
||||
padding: 8px 9px;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.cyber-agent__label {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cyber-agent__tasks {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cyber-agent__status {
|
||||
gap: 3px 5px;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="planner"] .cyber-agent__card {
|
||||
top: 35%;
|
||||
right: auto;
|
||||
left: 100%;
|
||||
width: 138px;
|
||||
transform: translate(8px, -12%) scale(2.27);
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="lead"] .cyber-agent__card {
|
||||
top: 12%;
|
||||
right: auto;
|
||||
left: 100%;
|
||||
width: 138px;
|
||||
transform: translate(8px, -12%) scale(2.38);
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="developer"] .cyber-agent__card {
|
||||
top: 12%;
|
||||
right: auto;
|
||||
left: 100%;
|
||||
width: 128px;
|
||||
transform: translate(8px, -12%) scale(2.5);
|
||||
transform-origin: left top;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.cyber-hero {
|
||||
padding: 84px 0 36px;
|
||||
|
|
@ -1889,12 +1967,14 @@
|
|||
|
||||
.cyber-hero__layout {
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cyber-hero__copy {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.cyber-hero__brand-lockup {
|
||||
|
|
@ -1970,14 +2050,14 @@
|
|||
.cyber-scene {
|
||||
min-height: auto;
|
||||
aspect-ratio: auto;
|
||||
padding: 92px 0 12px;
|
||||
padding: 96px 0 14px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.cyber-hero__scene {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-top: 18px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -1996,18 +2076,20 @@
|
|||
|
||||
.cyber-scene__robots {
|
||||
inset: 0 0 auto;
|
||||
height: 92px;
|
||||
height: 96px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
gap: clamp(18px, 6vw, 34px);
|
||||
}
|
||||
|
||||
.cyber-agent {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: none;
|
||||
left: auto;
|
||||
top: auto;
|
||||
width: 76px;
|
||||
width: clamp(58px, 18vw, 74px);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
|
|
@ -2015,11 +2097,46 @@
|
|||
display: block;
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="planner"] {
|
||||
z-index: auto;
|
||||
width: clamp(58px, 18vw, 74px);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="planner"] .cyber-agent__float {
|
||||
top: 4px;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="planner"] .cyber-agent__image {
|
||||
transform:
|
||||
scaleX(var(--agent-face))
|
||||
rotate(var(--agent-lean));
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="planner"] .cyber-agent__contact {
|
||||
left: 17%;
|
||||
right: 17%;
|
||||
bottom: 18px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="lead"] .cyber-agent__float {
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.cyber-agent[data-agent="developer"] .cyber-agent__float {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.cyber-agent__float {
|
||||
animation-duration: 6s;
|
||||
}
|
||||
|
||||
.cyber-agent__card,
|
||||
.cyber-agent .cyber-agent__card,
|
||||
.cyber-agent[data-agent="planner"] .cyber-agent__card,
|
||||
.cyber-agent[data-agent="lead"] .cyber-agent__card,
|
||||
.cyber-agent[data-agent="developer"] .cyber-agent__card,
|
||||
.cyber-agent__eyes {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -2034,6 +2151,33 @@
|
|||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.cyber-feature-rail-shell {
|
||||
margin-top: clamp(104px, 24vw, 128px);
|
||||
}
|
||||
|
||||
.cyber-feature-rail__collaboration {
|
||||
left: 31%;
|
||||
bottom: calc(100% + 8px);
|
||||
width: clamp(96px, 30vw, 124px);
|
||||
}
|
||||
|
||||
.cyber-feature-rail__reviewer {
|
||||
--reviewer-robot-width: clamp(58px, 18vw, 72px);
|
||||
|
||||
right: clamp(6px, 3vw, 16px);
|
||||
bottom: calc(100% + 8px);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__reviewer-card,
|
||||
.cyber-feature-rail__reviewer-bubble {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__robot {
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
.cyber-feature-rail__item {
|
||||
grid-template-columns: 48px 44px minmax(0, 1fr);
|
||||
grid-template-rows: auto;
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ function shouldUseBackgroundVideo() {
|
|||
backgroundPlaybackId.value &&
|
||||
isVisible &&
|
||||
!motionQuery?.matches &&
|
||||
!mobileQuery?.matches &&
|
||||
!hasBackgroundVideoError.value,
|
||||
);
|
||||
}
|
||||
|
|
@ -248,7 +249,7 @@ onMounted(() => {
|
|||
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
mobileQuery = window.matchMedia("(max-width: 700px)");
|
||||
motionQuery.addEventListener("change", syncMotionState);
|
||||
mobileQuery.addEventListener("change", syncGradient);
|
||||
mobileQuery.addEventListener("change", syncMotionState);
|
||||
|
||||
heroObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
|
|
@ -267,7 +268,7 @@ onMounted(() => {
|
|||
onBeforeUnmount(() => {
|
||||
heroObserver?.disconnect();
|
||||
motionQuery?.removeEventListener("change", syncMotionState);
|
||||
mobileQuery?.removeEventListener("change", syncGradient);
|
||||
mobileQuery?.removeEventListener("change", syncMotionState);
|
||||
stopBackgroundVideo();
|
||||
destroyGradient();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ const menuOpen = ref(false);
|
|||
const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`;
|
||||
const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/'));
|
||||
const isRu = computed(() => locale.value === 'ru');
|
||||
const openMenuLabel = computed(() => (isRu.value ? 'Открыть меню' : 'Open menu'));
|
||||
const closeMenuLabel = computed(() => (isRu.value ? 'Закрыть меню' : 'Close menu'));
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ href: '#screenshots', label: t('nav.screenshots'), shortLabel: isRu.value ? 'Скрины' : 'Shots' },
|
||||
|
|
@ -134,7 +136,7 @@ const navItems = computed(() => [
|
|||
<ThemeToggle />
|
||||
</div>
|
||||
<div class="app-header__mobile-actions">
|
||||
<v-btn :icon="mdiMenu" variant="text" @click="menuOpen = true" />
|
||||
<v-btn :icon="mdiMenu" variant="text" :aria-label="openMenuLabel" @click="menuOpen = true" />
|
||||
<Teleport to="body">
|
||||
<Transition name="mobile-menu-fade">
|
||||
<div v-if="menuOpen" class="mobile-menu-overlay" @click.self="menuOpen = false">
|
||||
|
|
@ -142,7 +144,13 @@ const navItems = computed(() => [
|
|||
<div class="mobile-menu__header">
|
||||
<AppLogo />
|
||||
<div style="flex: 1" />
|
||||
<v-btn :icon="mdiClose" variant="text" @click="menuOpen = false" />
|
||||
<v-btn
|
||||
:icon="mdiClose"
|
||||
variant="text"
|
||||
class="mobile-menu__close"
|
||||
:aria-label="closeMenuLabel"
|
||||
@click="menuOpen = false"
|
||||
/>
|
||||
</div>
|
||||
<hr class="mobile-menu__divider">
|
||||
<nav class="mobile-menu__list">
|
||||
|
|
@ -825,10 +833,43 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.mobile-menu__header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding-bottom: 14px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mobile-menu__close {
|
||||
width: 52px !important;
|
||||
min-width: 52px !important;
|
||||
height: 52px !important;
|
||||
color: var(--cyber-cyan) !important;
|
||||
border: 1px solid rgba(0, 234, 255, 0.82) !important;
|
||||
border-radius: 50% !important;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(0, 234, 255, 0.18), transparent 58%),
|
||||
rgba(2, 10, 24, 0.94) !important;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||
0 0 18px rgba(0, 234, 255, 0.38),
|
||||
0 0 36px rgba(0, 234, 255, 0.16);
|
||||
}
|
||||
|
||||
.mobile-menu__close :deep(.v-icon) {
|
||||
font-size: 30px;
|
||||
filter: drop-shadow(0 0 10px rgba(0, 234, 255, 0.6));
|
||||
}
|
||||
|
||||
.mobile-menu__close:hover {
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255, 255, 255, 0.86) !important;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(0, 234, 255, 0.28), transparent 62%),
|
||||
rgba(0, 234, 255, 0.16) !important;
|
||||
}
|
||||
|
||||
.mobile-menu__divider {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const ruNotes: Record<string, string> = {
|
|||
'5 columns, real-time': '5 колонок, в реальном времени',
|
||||
'Dashboard, not Kanban': 'Панель, не канбан',
|
||||
'7 columns, drag-and-drop': '7 колонок, перетаскивание',
|
||||
'Инструменты, ход рассуждений и таймлайн': 'Инструменты, ход рассуждений и таймлайн',
|
||||
'Tools, reasoning trace, and timeline': 'Инструменты, ход рассуждений и таймлайн',
|
||||
'Feed, metrics, dashboard': 'Лента, метрики, панель',
|
||||
'Agent chat + terminal': 'Чат агента и терминал',
|
||||
'View, stop, open URLs': 'Просмотр, остановка, открытие URL',
|
||||
|
|
@ -271,7 +271,7 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
},
|
||||
{
|
||||
feature: t('comparison.features.execLog'),
|
||||
us: { status: 'yes', note: note('Инструменты, ход рассуждений и таймлайн') },
|
||||
us: { status: 'yes', note: note('Tools, reasoning trace, and timeline') },
|
||||
gastown: { status: 'partial', note: note('Feed, metrics, dashboard') },
|
||||
paperclip: { status: 'yes', note: note('Run transcripts + audit log') },
|
||||
cursor: { status: 'partial', note: note('Agent chat + terminal') },
|
||||
|
|
|
|||
|
|
@ -84,6 +84,14 @@ const faqIcons = [
|
|||
<style scoped>
|
||||
.faq-section {
|
||||
position: relative;
|
||||
--faq-title-gradient: linear-gradient(135deg, #e0e6ff 0%, #ffd700 100%);
|
||||
--faq-subtitle-color: #8892b0;
|
||||
--faq-panel-bg: rgba(10, 10, 15, 0.8);
|
||||
--faq-panel-border: rgba(0, 240, 255, 0.08);
|
||||
--faq-panel-question: #e0e6ff;
|
||||
--faq-panel-answer: #8892b0;
|
||||
--faq-panel-hover-shadow: 0 8px 32px rgba(0, 240, 255, 0.06);
|
||||
--faq-panel-hover-border: rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
.faq-section__header {
|
||||
|
|
@ -100,7 +108,7 @@ const faqIcons = [
|
|||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #e0e6ff 0%, #ffd700 100%);
|
||||
background: var(--faq-title-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
|
|
@ -108,7 +116,7 @@ const faqIcons = [
|
|||
|
||||
.faq-section__subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #8892b0;
|
||||
color: var(--faq-subtitle-color);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -134,9 +142,10 @@ const faqIcons = [
|
|||
|
||||
.faq-section__panel {
|
||||
border-radius: 16px !important;
|
||||
background: rgba(10, 10, 15, 0.8) !important;
|
||||
border: 1px solid rgba(0, 240, 255, 0.08) !important;
|
||||
background: var(--faq-panel-bg) !important;
|
||||
border: 1px solid var(--faq-panel-border) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
color: var(--faq-panel-question) !important;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
overflow: hidden;
|
||||
animation: faqFadeIn 0.5s ease both;
|
||||
|
|
@ -145,8 +154,8 @@ const faqIcons = [
|
|||
|
||||
.faq-section__panel:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 240, 255, 0.2) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 240, 255, 0.06);
|
||||
border-color: var(--faq-panel-hover-border) !important;
|
||||
box-shadow: var(--faq-panel-hover-shadow);
|
||||
}
|
||||
|
||||
.faq-section__panel::after {
|
||||
|
|
@ -160,6 +169,8 @@ const faqIcons = [
|
|||
.faq-section__panel-title {
|
||||
padding: 20px 24px !important;
|
||||
min-height: unset !important;
|
||||
background: transparent !important;
|
||||
color: var(--faq-panel-question) !important;
|
||||
}
|
||||
|
||||
:deep(.faq-section__panel-title .v-expansion-panel-title__overlay) {
|
||||
|
|
@ -198,17 +209,18 @@ const faqIcons = [
|
|||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: #e0e6ff;
|
||||
color: var(--faq-panel-question);
|
||||
}
|
||||
|
||||
:deep(.faq-section__panel-text .v-expansion-panel-text__wrapper) {
|
||||
padding: 0 24px 20px 82px !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.faq-section__answer {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
color: #8892b0;
|
||||
color: var(--faq-panel-answer);
|
||||
}
|
||||
|
||||
.faq-section__answer :deep(a) {
|
||||
|
|
@ -300,32 +312,28 @@ const faqIcons = [
|
|||
}
|
||||
|
||||
/* Light Theme */
|
||||
.v-theme--light .faq-section__title {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #d97706 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
:global(.v-theme--light) .faq-section,
|
||||
:global(html.light) .faq-section {
|
||||
--faq-title-gradient: linear-gradient(135deg, #1e293b 0%, #d97706 100%);
|
||||
--faq-subtitle-color: #475569;
|
||||
--faq-panel-bg: rgba(255, 255, 255, 0.75);
|
||||
--faq-panel-border: rgba(0, 0, 0, 0.06);
|
||||
--faq-panel-question: #1e293b;
|
||||
--faq-panel-answer: #475569;
|
||||
--faq-panel-hover-shadow: 0 8px 32px rgba(0, 180, 200, 0.08);
|
||||
--faq-panel-hover-border: rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
.v-theme--light .faq-section__subtitle {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.v-theme--light .faq-section__panel {
|
||||
background: rgba(255, 255, 255, 0.75) !important;
|
||||
border-color: rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
.v-theme--light .faq-section__panel:hover {
|
||||
box-shadow: 0 8px 32px rgba(0, 180, 200, 0.08);
|
||||
}
|
||||
|
||||
.v-theme--light .faq-section__panel-question {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.v-theme--light .faq-section__answer {
|
||||
color: #475569;
|
||||
:global(.v-theme--dark) .faq-section,
|
||||
:global(html.dark) .faq-section {
|
||||
--faq-title-gradient: linear-gradient(135deg, #e0e6ff 0%, #ffd700 100%);
|
||||
--faq-subtitle-color: #8892b0;
|
||||
--faq-panel-bg: rgba(10, 10, 15, 0.8);
|
||||
--faq-panel-border: rgba(0, 240, 255, 0.08);
|
||||
--faq-panel-question: #e0e6ff;
|
||||
--faq-panel-answer: #8892b0;
|
||||
--faq-panel-hover-shadow: 0 8px 32px rgba(0, 240, 255, 0.06);
|
||||
--faq-panel-hover-border: rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
|
|
|
|||
|
|
@ -29,10 +29,29 @@ const muxPlayerUrl = computed(() => {
|
|||
url.searchParams.set("video-title", muxVideoTitle.value);
|
||||
return url.toString();
|
||||
});
|
||||
const muxPosterUrl = computed(() => {
|
||||
if (!muxPlaybackId.value) return "";
|
||||
|
||||
const url = new URL(`https://image.mux.com/${encodeURIComponent(muxPlaybackId.value)}/thumbnail.webp`);
|
||||
url.searchParams.set("time", "0.1");
|
||||
url.searchParams.set("width", "900");
|
||||
url.searchParams.set("fit_mode", "preserve");
|
||||
return url.toString();
|
||||
});
|
||||
const isLoaded = ref(false);
|
||||
const hasError = ref(false);
|
||||
const isMobileViewport = ref(false);
|
||||
const playerActivated = ref(false);
|
||||
const shouldShowMobilePoster = computed(() => (
|
||||
Boolean(muxPlayerUrl.value) &&
|
||||
!hasError.value &&
|
||||
isMobileViewport.value &&
|
||||
!playerActivated.value
|
||||
));
|
||||
const shouldShowPlayer = computed(() => Boolean(muxPlayerUrl.value) && !hasError.value && !shouldShowMobilePoster.value);
|
||||
|
||||
let loadFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let mobileQuery: MediaQueryList | null = null;
|
||||
|
||||
function clearLoadFallback() {
|
||||
if (!loadFallbackTimer) return;
|
||||
|
|
@ -51,11 +70,25 @@ function markError() {
|
|||
clearLoadFallback();
|
||||
}
|
||||
|
||||
function syncMobileViewport() {
|
||||
isMobileViewport.value = Boolean(mobileQuery?.matches);
|
||||
}
|
||||
|
||||
function activatePlayer() {
|
||||
playerActivated.value = true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mobileQuery = window.matchMedia("(max-width: 700px)");
|
||||
syncMobileViewport();
|
||||
mobileQuery.addEventListener("change", syncMobileViewport);
|
||||
loadFallbackTimer = setTimeout(markLoaded, 2500);
|
||||
});
|
||||
|
||||
onUnmounted(clearLoadFallback);
|
||||
onUnmounted(() => {
|
||||
mobileQuery?.removeEventListener("change", syncMobileViewport);
|
||||
clearLoadFallback();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -70,7 +103,7 @@ onUnmounted(clearLoadFallback);
|
|||
|
||||
<ClientOnly>
|
||||
<iframe
|
||||
v-if="muxPlayerUrl && !hasError"
|
||||
v-if="shouldShowPlayer"
|
||||
class="hero-video__player"
|
||||
:class="{ 'hero-video__player--loaded': isLoaded }"
|
||||
:src="muxPlayerUrl"
|
||||
|
|
@ -94,7 +127,21 @@ onUnmounted(clearLoadFallback);
|
|||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<div v-if="!isLoaded && !hasError && muxPlayerUrl" class="hero-video__skeleton">
|
||||
<button
|
||||
v-if="shouldShowMobilePoster"
|
||||
type="button"
|
||||
class="hero-video__poster"
|
||||
:style="{ '--hero-video-poster': muxPosterUrl ? `url(${muxPosterUrl})` : 'url(/screenshots/2.jpg)' }"
|
||||
:aria-label="videoTitle"
|
||||
@click="activatePlayer"
|
||||
>
|
||||
<span class="hero-video__poster-play">
|
||||
<v-icon :icon="mdiPlay" size="40" />
|
||||
</span>
|
||||
<span class="hero-video__poster-label">{{ t("hero.watchDemo") }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="!isLoaded && !hasError && shouldShowPlayer" class="hero-video__skeleton">
|
||||
<div class="hero-video__skeleton-pulse" />
|
||||
<div class="hero-video__skeleton-content">
|
||||
<div class="hero-video__skeleton-spinner" />
|
||||
|
|
@ -265,6 +312,69 @@ onUnmounted(clearLoadFallback);
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.hero-video__poster {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
color: rgba(230, 251, 255, 0.94);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(2, 6, 16, 0.18), rgba(2, 6, 16, 0.36)),
|
||||
linear-gradient(180deg, rgba(0, 234, 255, 0.06), rgba(255, 43, 255, 0.08)),
|
||||
var(--hero-video-poster) center / cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-video__poster::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(0, 240, 255, 0.16), transparent 34%),
|
||||
repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 4px);
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-video__poster-play,
|
||||
.hero-video__poster-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-video__poster-play {
|
||||
display: grid;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(0, 240, 255, 0.58);
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
background: rgba(2, 10, 24, 0.68);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||
0 0 24px rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
|
||||
.hero-video__poster-label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: rgba(0, 240, 255, 0.9);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 16px rgba(0, 240, 255, 0.42);
|
||||
}
|
||||
|
||||
.hero-video__skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -398,6 +508,10 @@ onUnmounted(clearLoadFallback);
|
|||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hero-video__poster {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hero-video__edge {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ export const useBrowserTheme = () => {
|
|||
const applyDocumentTheme = (name: ThemeName) => {
|
||||
if (!import.meta.client) return;
|
||||
|
||||
document.documentElement.classList.toggle("dark", name === "dark");
|
||||
document.documentElement.classList.toggle("light", name === "light");
|
||||
document.documentElement.style.colorScheme = name;
|
||||
|
||||
document.querySelectorAll(".v-application").forEach((app) => {
|
||||
app.classList.toggle("v-theme--dark", name === "dark");
|
||||
app.classList.toggle("v-theme--light", name === "light");
|
||||
|
|
|
|||
4374
landing/package-lock.json
generated
|
|
@ -24,7 +24,7 @@
|
|||
"@nuxtjs/i18n": "^9.5.6",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@vueuse/nuxt": "^10.11.1",
|
||||
"nuxt": "^3.20.2",
|
||||
"nuxt": "^3.21.6",
|
||||
"nuxt-icon": "^0.6.10",
|
||||
"pinia": "^3.0.4",
|
||||
"swiper": "^12.1.2",
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"@nuxt/eslint": "^1.12.1",
|
||||
"@shikijs/transformers": "3.22.0",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^9.39.4",
|
||||
"medium-zoom": "^1.1.0",
|
||||
"prettier": "^3.8.0",
|
||||
"sass": "^1.97.2",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"agent-teams-controller": "workspace:*",
|
||||
"fastmcp": "^3.34.0",
|
||||
"fastmcp": "^3.35.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
84
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "agent-teams-ai",
|
||||
"type": "module",
|
||||
"version": "2.1.1",
|
||||
"version": "2.1.2",
|
||||
"description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
|
||||
"license": "AGPL-3.0",
|
||||
"author": {
|
||||
|
|
@ -198,7 +198,7 @@
|
|||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.3",
|
||||
"@electron/rebuild": "^4.0.4",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sentry/vite-plugin": "^5.1.1",
|
||||
|
|
@ -216,7 +216,7 @@
|
|||
"electron": "^40.10.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-boundaries": "^5.3.1",
|
||||
|
|
@ -230,18 +230,18 @@
|
|||
"eslint-plugin-sonarjs": "^3.0.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"globals": "^17.2.0",
|
||||
"happy-dom": "^20.0.2",
|
||||
"happy-dom": "^20.9.0",
|
||||
"husky": "^9.1.7",
|
||||
"knip": "^5.82.1",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.4.35",
|
||||
"postcss": "^8.5.10",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^5.4.2",
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"build": {
|
||||
|
|
@ -251,10 +251,29 @@
|
|||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"out/renderer/**",
|
||||
"dist-electron/**",
|
||||
"package.json",
|
||||
"!**/*.map"
|
||||
{
|
||||
"from": "out/renderer",
|
||||
"to": "out/renderer",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!**/*.map"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "dist-electron",
|
||||
"to": "dist-electron",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"!**/*.map"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": ".",
|
||||
"to": ".",
|
||||
"filter": [
|
||||
"package.json"
|
||||
]
|
||||
}
|
||||
],
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
|
|
@ -311,6 +330,7 @@
|
|||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"executableName": "AgentTeamsAI",
|
||||
"icon": "resources/icons/win/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
|
|
@ -348,7 +368,8 @@
|
|||
"artifactName": "Agent.Teams.AI.Setup.${version}.${ext}",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"shortcutName": "Agent Teams AI"
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
|
|
@ -362,8 +383,47 @@
|
|||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@hono/node-server@1": "1.19.13",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"axios": "1.15.2",
|
||||
"brace-expansion@1": "1.1.13",
|
||||
"brace-expansion@2": "2.0.3",
|
||||
"brace-expansion@5": "5.0.6",
|
||||
"defu": "6.1.5",
|
||||
"devalue": "5.8.1",
|
||||
"fast-uri": "3.1.2",
|
||||
"file-type@21": "21.3.2",
|
||||
"flatted": "3.4.2",
|
||||
"follow-redirects": "1.16.0",
|
||||
"handlebars": "4.7.9",
|
||||
"hono": "4.12.18",
|
||||
"ip-address": "10.1.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"uuid": "^11.1.1"
|
||||
"@nuxt/devtools-kit>vite": "7.3.2",
|
||||
"minimatch@3": "3.1.4",
|
||||
"minimatch@5": "5.1.8",
|
||||
"minimatch@9": "9.0.7",
|
||||
"minimatch@10": "10.2.3",
|
||||
"nitropack": "2.13.4",
|
||||
"node-forge": "1.4.0",
|
||||
"path-to-regexp": "8.4.0",
|
||||
"picomatch@2": "2.3.2",
|
||||
"picomatch@4": "4.0.4",
|
||||
"postcss": "8.5.10",
|
||||
"qs": "6.15.2",
|
||||
"rollup": "4.59.0",
|
||||
"serialize-javascript": "7.0.5",
|
||||
"simple-git": "3.36.0",
|
||||
"smol-toml": "1.6.1",
|
||||
"srvx": "0.11.13",
|
||||
"tar": "7.5.11",
|
||||
"undici@7": "7.24.0",
|
||||
"unhead": "2.1.13",
|
||||
"uuid": "^11.1.1",
|
||||
"vitepress>vite": "7.3.2",
|
||||
"ws": "8.20.1",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
|
|
|
|||
4478
pnpm-lock.yaml
|
|
@ -3,5 +3,10 @@ packages:
|
|||
- mcp-server
|
||||
- landing
|
||||
- packages/agent-graph
|
||||
minimumReleaseAge: 4320
|
||||
strictPeerDependencies: true
|
||||
peerDependencyRules:
|
||||
allowedVersions:
|
||||
"@nuxt/schema": "3.21.6"
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
|
|
|||
|
|
@ -317,6 +317,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
|
|
@ -347,6 +348,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
|
|
@ -377,6 +379,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
|
|
@ -406,6 +409,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
|
|
@ -435,6 +439,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
|
|
@ -634,6 +639,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"global.anthropic.claude-sonnet-4-6": {
|
||||
|
|
@ -664,6 +670,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"us.anthropic.claude-sonnet-4-6": {
|
||||
|
|
@ -694,6 +701,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"eu.anthropic.claude-sonnet-4-6": {
|
||||
|
|
@ -723,6 +731,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"au.anthropic.claude-sonnet-4-6": {
|
||||
|
|
@ -752,6 +761,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"jp.anthropic.claude-sonnet-4-6": {
|
||||
|
|
@ -781,6 +791,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_output_config": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0": {
|
||||
|
|
@ -1085,6 +1096,7 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 159,
|
||||
"supports_output_config": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
|
|
@ -1182,6 +1194,7 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_output_config": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"bedrock/ap-northeast-1/anthropic.claude-instant-v1": {
|
||||
|
|
@ -1761,6 +1774,7 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_output_config": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"claude-sonnet-4-5-20250929-v1:0": {
|
||||
|
|
@ -1958,6 +1972,7 @@
|
|||
"us": 1.1,
|
||||
"fast": 6
|
||||
},
|
||||
"supports_output_config": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
|
|
@ -1993,7 +2008,8 @@
|
|||
"fast": 6
|
||||
},
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_output_config": true
|
||||
},
|
||||
"claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -2028,7 +2044,8 @@
|
|||
"us": 1.1,
|
||||
"fast": 6
|
||||
},
|
||||
"supports_minimal_reasoning_effort": true
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_output_config": true
|
||||
},
|
||||
"claude-opus-4-7-20260416": {
|
||||
"cache_creation_input_token_cost": 0.00000625,
|
||||
|
|
@ -2063,7 +2080,8 @@
|
|||
"us": 1.1,
|
||||
"fast": 6
|
||||
},
|
||||
"supports_minimal_reasoning_effort": true
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_output_config": true
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
"deprecation_date": "2026-05-14",
|
||||
|
|
@ -4564,6 +4582,7 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_output_config": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
|
|
@ -4592,6 +4611,7 @@
|
|||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_output_config": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
|
|
@ -4705,6 +4725,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_output_config": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"vertex_ai/claude-sonnet-4-5@20250929": {
|
||||
|
|
@ -4846,6 +4867,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_output_config": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.46",
|
||||
"sourceRef": "v0.0.46",
|
||||
"version": "0.0.47",
|
||||
"sourceRef": "v0.0.47",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/agent-teams-ai",
|
||||
"releaseTag": "v2.1.1",
|
||||
"releaseTag": "v2.1.2",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.46.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.47.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.46.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.47.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.46.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.47.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.46.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.47.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,9 +92,7 @@ function shouldKeepNodePtyPrebuild(entryName, platform, archLabel) {
|
|||
|
||||
if (platform === 'darwin' && archLabel === 'universal') {
|
||||
return (
|
||||
entryName === 'darwin-universal' ||
|
||||
entryName === 'darwin-arm64' ||
|
||||
entryName === 'darwin-x64'
|
||||
entryName === 'darwin-universal' || entryName === 'darwin-arm64' || entryName === 'darwin-x64'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -154,11 +152,85 @@ async function pruneNodePtyArtifacts(appOutDir, platform, archLabel) {
|
|||
removedPaths.push(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
const hasTargetPrebuild = await hasNodePtyTargetPrebuild(nodePtyRoot, platform, archLabel);
|
||||
if (!hasTargetPrebuild) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const buildName of ['Release', 'Debug']) {
|
||||
const buildDir = path.join(nodePtyRoot, 'build', buildName);
|
||||
if (!(await directoryExists(buildDir))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await containsIncompatibleNativeBinary(buildDir, platform, archLabel)) {
|
||||
await fs.promises.rm(buildDir, { recursive: true, force: true });
|
||||
removedPaths.push(buildDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removedPaths;
|
||||
}
|
||||
|
||||
async function directoryExists(dirPath) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(dirPath);
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasNodePtyTargetPrebuild(nodePtyRoot, platform, archLabel) {
|
||||
const prebuildsDir = path.join(nodePtyRoot, 'prebuilds');
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.promises.readdir(prebuildsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !shouldKeepNodePtyPrebuild(entry.name, platform, archLabel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ptyNativePath = path.join(prebuildsDir, entry.name, 'pty.node');
|
||||
if (await fileExists(ptyNativePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
return stat.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function containsIncompatibleNativeBinary(rootDir, targetPlatform, targetArch) {
|
||||
const files = await walkFiles(rootDir);
|
||||
for (const filePath of files) {
|
||||
const metadata = await detectBinaryMetadata(filePath);
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isBinaryCompatible(metadata.format, metadata.archs, targetPlatform, targetArch)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function findNodeModulesSequence(segments, sequence) {
|
||||
for (let index = 0; index <= segments.length - sequence.length; index += 1) {
|
||||
let matches = true;
|
||||
|
|
@ -332,12 +404,7 @@ function parseElf(buffer) {
|
|||
if (buffer.length < 20) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
buffer[0] !== 0x7f ||
|
||||
buffer[1] !== 0x45 ||
|
||||
buffer[2] !== 0x4c ||
|
||||
buffer[3] !== 0x46
|
||||
) {
|
||||
if (buffer[0] !== 0x7f || buffer[1] !== 0x45 || buffer[2] !== 0x4c || buffer[3] !== 0x46) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -454,11 +521,7 @@ async function afterPack(context) {
|
|||
|
||||
const removedPaths = [
|
||||
...(await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch)),
|
||||
...(await pruneKnownIncompatibleNativeArtifacts(
|
||||
context.appOutDir,
|
||||
targetPlatform,
|
||||
targetArch
|
||||
)),
|
||||
...(await pruneKnownIncompatibleNativeArtifacts(context.appOutDir, targetPlatform, targetArch)),
|
||||
];
|
||||
const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { scheduleStartupIdleTask } from '@renderer/utils/startupIdleTask';
|
||||
|
||||
import type {
|
||||
CodexAccountSnapshotDto,
|
||||
|
|
@ -11,6 +12,9 @@ const CODEX_PENDING_LOGIN_REFRESH_MS = 3_000;
|
|||
const CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS = 10_000;
|
||||
const CODEX_VISIBLE_STANDARD_REFRESH_MS = 20_000;
|
||||
const CODEX_HIDDEN_REFRESH_MS = 60_000;
|
||||
export const CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS = 2_000;
|
||||
export const CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS = 30_000;
|
||||
export const CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS = CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS;
|
||||
|
||||
function isDocumentVisible(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
|
|
@ -41,6 +45,8 @@ function getRefreshIntervalMs(options: {
|
|||
export function useCodexAccountSnapshot(options: {
|
||||
enabled: boolean;
|
||||
includeRateLimits?: boolean;
|
||||
initialRefreshDelayMs?: number;
|
||||
initialRefreshMaxDelayMs?: number;
|
||||
}): {
|
||||
snapshot: CodexAccountSnapshotDto | null;
|
||||
loading: boolean;
|
||||
|
|
@ -62,6 +68,11 @@ export function useCodexAccountSnapshot(options: {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(() => isDocumentVisible());
|
||||
const lastUpdatedAtRef = useRef<number | null>(null);
|
||||
const initialRefreshDelayMs = options.initialRefreshDelayMs ?? 0;
|
||||
const initialRefreshMaxDelayMs = options.initialRefreshMaxDelayMs;
|
||||
const [initialRefreshAttempted, setInitialRefreshAttempted] = useState(
|
||||
() => initialRefreshDelayMs <= 0
|
||||
);
|
||||
|
||||
const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
|
||||
lastUpdatedAtRef.current = Date.now();
|
||||
|
|
@ -117,38 +128,85 @@ export function useCodexAccountSnapshot(options: {
|
|||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
if (options.includeRateLimits) {
|
||||
setRateLimitsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
let active = true;
|
||||
let cancelInitialRefresh: (() => void) | null = null;
|
||||
|
||||
const initialSnapshotRequest = options.includeRateLimits
|
||||
? api.refreshCodexAccountSnapshot({
|
||||
includeRateLimits: true,
|
||||
const startInitialSnapshotRequest = (): void => {
|
||||
if (!active || lastUpdatedAtRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
if (options.includeRateLimits) {
|
||||
setRateLimitsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const initialSnapshotRequest = options.includeRateLimits
|
||||
? api.refreshCodexAccountSnapshot({
|
||||
includeRateLimits: true,
|
||||
})
|
||||
: api.getCodexAccountSnapshot();
|
||||
|
||||
void initialSnapshotRequest
|
||||
.then((nextSnapshot) => {
|
||||
if (active) {
|
||||
applySnapshot(nextSnapshot);
|
||||
}
|
||||
})
|
||||
: api.getCodexAccountSnapshot();
|
||||
.catch((nextError) => {
|
||||
if (active) {
|
||||
setError(
|
||||
nextError instanceof Error ? nextError.message : 'Failed to load Codex account'
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setInitialRefreshAttempted(true);
|
||||
setLoading(false);
|
||||
if (options.includeRateLimits) {
|
||||
setRateLimitsLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
void initialSnapshotRequest
|
||||
.then((nextSnapshot) => {
|
||||
applySnapshot(nextSnapshot);
|
||||
})
|
||||
.catch((nextError) => {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load Codex account');
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
if (options.includeRateLimits) {
|
||||
setRateLimitsLoading(false);
|
||||
}
|
||||
});
|
||||
if (initialRefreshDelayMs > 0) {
|
||||
if (typeof initialRefreshMaxDelayMs === 'number') {
|
||||
cancelInitialRefresh = scheduleStartupIdleTask(startInitialSnapshotRequest, {
|
||||
minDelayMs: initialRefreshDelayMs,
|
||||
maxDelayMs: initialRefreshMaxDelayMs,
|
||||
});
|
||||
} else {
|
||||
const initialRefreshTimer = window.setTimeout(
|
||||
startInitialSnapshotRequest,
|
||||
initialRefreshDelayMs
|
||||
);
|
||||
cancelInitialRefresh = () => window.clearTimeout(initialRefreshTimer);
|
||||
}
|
||||
} else {
|
||||
startInitialSnapshotRequest();
|
||||
}
|
||||
|
||||
const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => {
|
||||
applySnapshot(nextSnapshot);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [applySnapshot, electronMode, options.enabled, options.includeRateLimits]);
|
||||
return () => {
|
||||
active = false;
|
||||
cancelInitialRefresh?.();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [
|
||||
applySnapshot,
|
||||
electronMode,
|
||||
initialRefreshDelayMs,
|
||||
initialRefreshMaxDelayMs,
|
||||
options.enabled,
|
||||
options.includeRateLimits,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronMode || !options.enabled || typeof document === 'undefined') {
|
||||
|
|
@ -167,6 +225,15 @@ export function useCodexAccountSnapshot(options: {
|
|||
? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS
|
||||
: CODEX_VISIBLE_STANDARD_REFRESH_MS;
|
||||
|
||||
if (
|
||||
initialRefreshDelayMs > 0 &&
|
||||
lastUpdatedAtRef.current === null &&
|
||||
snapshot === null &&
|
||||
!initialRefreshAttempted
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
lastUpdatedAtRef.current === null ||
|
||||
Date.now() - lastUpdatedAtRef.current >= staleAfterMs
|
||||
|
|
@ -182,12 +249,23 @@ export function useCodexAccountSnapshot(options: {
|
|||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [electronMode, options.enabled, options.includeRateLimits, refresh]);
|
||||
}, [
|
||||
electronMode,
|
||||
initialRefreshAttempted,
|
||||
initialRefreshDelayMs,
|
||||
options.enabled,
|
||||
options.includeRateLimits,
|
||||
refresh,
|
||||
snapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronMode || !options.enabled) {
|
||||
return;
|
||||
}
|
||||
if (initialRefreshDelayMs > 0 && snapshot === null && !initialRefreshAttempted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshIntervalMs = getRefreshIntervalMs({
|
||||
loginStatus: snapshot?.login.status,
|
||||
|
|
@ -206,9 +284,12 @@ export function useCodexAccountSnapshot(options: {
|
|||
};
|
||||
}, [
|
||||
electronMode,
|
||||
initialRefreshAttempted,
|
||||
initialRefreshDelayMs,
|
||||
options.enabled,
|
||||
options.includeRateLimits,
|
||||
refresh,
|
||||
snapshot,
|
||||
snapshot?.login.status,
|
||||
visible,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
export { useCodexAccountSnapshot } from './hooks/useCodexAccountSnapshot';
|
||||
export {
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_DELAY_MS,
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
|
||||
useCodexAccountSnapshot,
|
||||
} from './hooks/useCodexAccountSnapshot';
|
||||
export { mergeCodexCliStatusWithSnapshot } from './mergeCodexCliStatusWithSnapshot';
|
||||
export { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot';
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type { ChildProcessWithoutNullStreams } from 'child_process';
|
|||
|
||||
const PROBE_COMMAND_TIMEOUT_MS = 90_000;
|
||||
const COMMAND_TIMEOUT_MS = PROBE_COMMAND_TIMEOUT_MS;
|
||||
const COMMAND_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||
const COMMAND_ERROR_DETAIL_LIMIT = 1_600;
|
||||
const COMMAND_OUTPUT_PREVIEW_LIMIT = 1_200;
|
||||
const ESCAPE_CHARACTER = String.fromCharCode(27);
|
||||
|
|
@ -870,8 +871,12 @@ function appendOptionalArg(args: string[], name: string, value: string | null |
|
|||
function runtimeProviderCommandOptions<T extends { env: NodeJS.ProcessEnv }>(
|
||||
options: T,
|
||||
projectPath: string | null
|
||||
): T & { cwd?: string } {
|
||||
return projectPath ? { ...options, cwd: projectPath } : options;
|
||||
): T & { cwd?: string; maxBuffer: number } {
|
||||
const commandOptions = {
|
||||
...options,
|
||||
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
||||
};
|
||||
return projectPath ? { ...commandOptions, cwd: projectPath } : commandOptions;
|
||||
}
|
||||
|
||||
async function resolveCliEnv(): Promise<{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import {
|
||||
compareOpenCodeTeamModelRecommendations,
|
||||
getOpenCodeTeamModelRecommendation,
|
||||
|
|
@ -819,7 +820,10 @@ const RuntimeProviderErrorAlert = ({
|
|||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 shrink-0 px-2 text-[11px]"
|
||||
className={cn(
|
||||
'h-6 shrink-0 px-2 text-[11px]',
|
||||
!copied && 'member-launch-diagnostics-pulse'
|
||||
)}
|
||||
title={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
|
||||
aria-label={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
|
||||
onClick={(event) => {
|
||||
|
|
|
|||
|
|
@ -354,10 +354,18 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str
|
|||
return 'The current review request is still waiting for explicit review pickup.';
|
||||
}
|
||||
|
||||
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise<string | null> {
|
||||
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath();
|
||||
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(options?: {
|
||||
includeShellEnv?: boolean;
|
||||
}): Promise<string | null> {
|
||||
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath({
|
||||
includeShellEnv: options?.includeShellEnv,
|
||||
});
|
||||
if (resolvedBinaryPath) return resolvedBinaryPath;
|
||||
|
||||
if (options?.includeShellEnv === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await openCodeRuntimeInstallerService?.getStatus();
|
||||
return status?.installed === true && status.binaryPath ? status.binaryPath : null;
|
||||
|
|
@ -448,15 +456,18 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv);
|
||||
}
|
||||
};
|
||||
const ensureOpenCodeRuntimeBinaryEnv = async (targetEnv: NodeJS.ProcessEnv): Promise<void> => {
|
||||
const ensureOpenCodeRuntimeBinaryEnv = async (
|
||||
targetEnv: NodeJS.ProcessEnv,
|
||||
options: { includeShellEnv?: boolean } = {}
|
||||
): Promise<void> => {
|
||||
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||
targetEnv,
|
||||
bridgeEnv,
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath: () =>
|
||||
resolveOpenCodeRuntimeBinaryForBridgeEnv({ includeShellEnv: options.includeShellEnv }),
|
||||
onWarning: (message) => logger.warn(message),
|
||||
});
|
||||
};
|
||||
await ensureOpenCodeRuntimeBinaryEnv(bridgeEnv);
|
||||
try {
|
||||
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||
|
|
@ -500,7 +511,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
||||
const nextEnv = { ...bridgeEnv };
|
||||
await ensureOpenCodeRuntimeBinaryEnv(nextEnv);
|
||||
await ensureOpenCodeRuntimeBinaryEnv(nextEnv, { includeShellEnv: true });
|
||||
if (!useHttpMcpBridge) {
|
||||
return nextEnv;
|
||||
}
|
||||
|
|
@ -917,6 +928,8 @@ const startupTimers = new Set<ReturnType<typeof setTimeout>>();
|
|||
|
||||
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
|
||||
const STARTUP_RECOVERY_DELAY_MS = 10_000;
|
||||
const STARTUP_CLI_WARMUP_DELAY_MS = 90_000;
|
||||
const STARTUP_BACKGROUND_SERVICE_DELAY_MS = 5_000;
|
||||
const STARTUP_RECOVERY_CONCURRENCY = 1;
|
||||
const appStartupStartedAt = Date.now();
|
||||
let appStartupSteps: AppStartupStep[] = [
|
||||
|
|
@ -2456,10 +2469,12 @@ function runPostRendererStartupTasks(): void {
|
|||
);
|
||||
|
||||
scheduleStartupTask(() => {
|
||||
void teamProvisioningService.warmup();
|
||||
teamDataService.startProcessHealthPolling();
|
||||
void schedulerService?.start();
|
||||
}, 5000);
|
||||
}, STARTUP_BACKGROUND_SERVICE_DELAY_MS);
|
||||
scheduleStartupTask(() => {
|
||||
void teamProvisioningService.warmup();
|
||||
}, STARTUP_CLI_WARMUP_DELAY_MS);
|
||||
}
|
||||
|
||||
function scheduleRendererRecovery(win: BrowserWindow): void {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
CLI_INSTALLER_VERIFY_PROVIDER_MODELS,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
|
|
@ -24,6 +25,8 @@ import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
|||
import type { CliInstallerService } from '../services';
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
CliInstallerGetStatusOptions,
|
||||
CliInstallerProviderStatusMode,
|
||||
CliProviderId,
|
||||
CliProviderStatus,
|
||||
IpcResult,
|
||||
|
|
@ -33,9 +36,12 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
|||
const logger = createLogger('IPC:cliInstaller');
|
||||
|
||||
let service: CliInstallerService;
|
||||
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
||||
const statusInFlight = new Map<CliInstallerProviderStatusMode, Promise<CliInstallationStatus>>();
|
||||
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
||||
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
||||
const cachedStatus = new Map<
|
||||
CliInstallerProviderStatusMode,
|
||||
{ value: CliInstallationStatus; at: number }
|
||||
>();
|
||||
let statusCacheGeneration = 0;
|
||||
const STATUS_CACHE_TTL_MS = 5_000;
|
||||
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']);
|
||||
|
|
@ -54,6 +60,56 @@ function getCachedStatusAuthenticatedProvider(
|
|||
);
|
||||
}
|
||||
|
||||
function normalizeGetStatusOptions(options: unknown): Required<CliInstallerGetStatusOptions> {
|
||||
if (
|
||||
typeof options === 'object' &&
|
||||
options !== null &&
|
||||
(options as CliInstallerGetStatusOptions).providerStatusMode === 'defer'
|
||||
) {
|
||||
return { providerStatusMode: 'defer' };
|
||||
}
|
||||
|
||||
return { providerStatusMode: 'full' };
|
||||
}
|
||||
|
||||
function isDeferredProviderStatusSnapshot(status: CliInstallationStatus): boolean {
|
||||
return (
|
||||
status.flavor === 'agent_teams_orchestrator' &&
|
||||
status.providers.length > 0 &&
|
||||
status.providers.every(
|
||||
(provider) =>
|
||||
provider.supported === false &&
|
||||
provider.authenticated === false &&
|
||||
provider.verificationState === 'unknown' &&
|
||||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasDeferredProviderStatus(status: CliInstallationStatus): boolean {
|
||||
return (
|
||||
status.flavor === 'agent_teams_orchestrator' &&
|
||||
status.providers.some(
|
||||
(provider) => provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canUseStatusForCacheKey(
|
||||
cacheKey: CliInstallerProviderStatusMode,
|
||||
status: CliInstallationStatus
|
||||
): boolean {
|
||||
if (cacheKey === 'defer') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
!status.authStatusChecking &&
|
||||
!hasDeferredProviderStatus(status) &&
|
||||
!isDeferredProviderStatusSnapshot(status)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes CLI installer handlers with the service instance.
|
||||
*/
|
||||
|
|
@ -92,32 +148,36 @@ export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
|
|||
// =============================================================================
|
||||
|
||||
async function handleGetStatus(
|
||||
_event: IpcMainInvokeEvent
|
||||
_event: IpcMainInvokeEvent,
|
||||
options?: CliInstallerGetStatusOptions
|
||||
): Promise<IpcResult<CliInstallationStatus>> {
|
||||
try {
|
||||
const normalizedOptions = normalizeGetStatusOptions(options);
|
||||
const cacheKey = normalizedOptions.providerStatusMode;
|
||||
const latestSnapshot = service.getLatestStatusSnapshot();
|
||||
if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) {
|
||||
if (latestSnapshot) {
|
||||
cachedStatus = { value: latestSnapshot, at: Date.now() };
|
||||
const cached = cachedStatus.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < STATUS_CACHE_TTL_MS) {
|
||||
if (latestSnapshot && canUseStatusForCacheKey(cacheKey, latestSnapshot)) {
|
||||
cachedStatus.set(cacheKey, { value: latestSnapshot, at: Date.now() });
|
||||
return { success: true, data: latestSnapshot };
|
||||
}
|
||||
return { success: true, data: cachedStatus.value };
|
||||
return { success: true, data: cached.value };
|
||||
}
|
||||
|
||||
if (!statusInFlight) {
|
||||
if (!statusInFlight.has(cacheKey)) {
|
||||
const startedAt = Date.now();
|
||||
const generation = statusCacheGeneration;
|
||||
const request = service
|
||||
.getStatus()
|
||||
.getStatus(normalizedOptions)
|
||||
.then((status) => {
|
||||
if (generation === statusCacheGeneration) {
|
||||
cachedStatus = { value: status, at: Date.now() };
|
||||
if (generation === statusCacheGeneration && canUseStatusForCacheKey(cacheKey, status)) {
|
||||
cachedStatus.set(cacheKey, { value: status, at: Date.now() });
|
||||
}
|
||||
return status;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (generation === statusCacheGeneration) {
|
||||
cachedStatus = null;
|
||||
cachedStatus.delete(cacheKey);
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
|
|
@ -126,14 +186,14 @@ async function handleGetStatus(
|
|||
if (ms >= 2000) {
|
||||
logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
|
||||
}
|
||||
if (statusInFlight === request) {
|
||||
statusInFlight = null;
|
||||
if (statusInFlight.get(cacheKey) === request) {
|
||||
statusInFlight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
statusInFlight = request;
|
||||
statusInFlight.set(cacheKey, request);
|
||||
}
|
||||
|
||||
const status = await statusInFlight;
|
||||
const status = await statusInFlight.get(cacheKey)!;
|
||||
return { success: true, data: status };
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
|
|
@ -143,42 +203,44 @@ async function handleGetStatus(
|
|||
}
|
||||
|
||||
function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void {
|
||||
if (!cachedStatus || !providerStatus) {
|
||||
if (!providerStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
cachedStatus.value.flavor === 'agent_teams_orchestrator' &&
|
||||
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||
) {
|
||||
return;
|
||||
for (const [cacheKey, cached] of cachedStatus) {
|
||||
if (
|
||||
cached.value.flavor === 'agent_teams_orchestrator' &&
|
||||
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasProvider = cached.value.providers.some(
|
||||
(provider) => provider.providerId === providerStatus.providerId
|
||||
);
|
||||
const nextProviders = hasProvider
|
||||
? cached.value.providers.map((provider) =>
|
||||
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
||||
)
|
||||
: [...cached.value.providers, providerStatus];
|
||||
const authenticatedProvider =
|
||||
cached.value.flavor === 'agent_teams_orchestrator'
|
||||
? getCachedStatusAuthenticatedProvider(nextProviders)
|
||||
: (nextProviders.find((provider) => provider.authenticated) ?? null);
|
||||
|
||||
cachedStatus.set(cacheKey, {
|
||||
value: {
|
||||
...cached.value,
|
||||
providers: nextProviders,
|
||||
authLoggedIn:
|
||||
cached.value.flavor === 'agent_teams_orchestrator'
|
||||
? authenticatedProvider !== null
|
||||
: nextProviders.some((provider) => provider.authenticated),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
},
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const hasProvider = cachedStatus.value.providers.some(
|
||||
(provider) => provider.providerId === providerStatus.providerId
|
||||
);
|
||||
const nextProviders = hasProvider
|
||||
? cachedStatus.value.providers.map((provider) =>
|
||||
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
||||
)
|
||||
: [...cachedStatus.value.providers, providerStatus];
|
||||
const authenticatedProvider =
|
||||
cachedStatus.value.flavor === 'agent_teams_orchestrator'
|
||||
? getCachedStatusAuthenticatedProvider(nextProviders)
|
||||
: (nextProviders.find((provider) => provider.authenticated) ?? null);
|
||||
|
||||
cachedStatus = {
|
||||
value: {
|
||||
...cachedStatus.value,
|
||||
providers: nextProviders,
|
||||
authLoggedIn:
|
||||
cachedStatus.value.flavor === 'agent_teams_orchestrator'
|
||||
? authenticatedProvider !== null
|
||||
: nextProviders.some((provider) => provider.authenticated),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
},
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGetProviderStatus(
|
||||
|
|
@ -248,8 +310,8 @@ async function handleVerifyProviderModels(
|
|||
|
||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||
statusCacheGeneration += 1;
|
||||
cachedStatus = null;
|
||||
statusInFlight = null;
|
||||
cachedStatus.clear();
|
||||
statusInFlight.clear();
|
||||
providerStatusInFlight.clear();
|
||||
ClaudeBinaryResolver.clearCache();
|
||||
CodexBinaryResolver.clearCache();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
getShellPreferredHome,
|
||||
resolveInteractiveShellEnvBestEffort,
|
||||
} from '@main/utils/shellEnv';
|
||||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
|
@ -49,7 +50,9 @@ import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor
|
|||
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
CliInstallerGetStatusOptions,
|
||||
CliInstallerProgress,
|
||||
CliInstallerProviderStatusMode,
|
||||
CliPlatform,
|
||||
CliProviderId,
|
||||
CliProviderModelAvailability,
|
||||
|
|
@ -171,6 +174,7 @@ function parseClaudeAuthStatusStdout(stdout: string): { loggedIn?: boolean; auth
|
|||
|
||||
/** NDJSON: strip C0 controls (except \\t \\n \\r) so logs stay valid text and tiny. */
|
||||
function stripControlForDiag(s: string): string {
|
||||
// eslint-disable-next-line no-control-regex -- Strip raw terminal C0 controls before diagnostic logging.
|
||||
return s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '\uFFFD');
|
||||
}
|
||||
|
||||
|
|
@ -868,8 +872,9 @@ export class CliInstallerService {
|
|||
// Public: getStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getStatus(): Promise<CliInstallationStatus> {
|
||||
async getStatus(options: CliInstallerGetStatusOptions = {}): Promise<CliInstallationStatus> {
|
||||
const statusStartedAt = Date.now();
|
||||
const providerStatusMode: CliInstallerProviderStatusMode = options.providerStatusMode ?? 'full';
|
||||
const generation = ++this.statusGatherGeneration;
|
||||
const result = this.createInitialStatus();
|
||||
this.latestProviderSignatures.clear();
|
||||
|
|
@ -882,7 +887,7 @@ export class CliInstallerService {
|
|||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
this.gatherStatus(ref, runDiag, generation),
|
||||
this.gatherStatus(ref, runDiag, generation, providerStatusMode),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
logger.warn(
|
||||
|
|
@ -1023,16 +1028,21 @@ export class CliInstallerService {
|
|||
private async gatherStatus(
|
||||
ref: { current: CliInstallationStatus },
|
||||
diag: CliInstallerStatusRunDiag,
|
||||
generation: number
|
||||
generation: number,
|
||||
providerStatusMode: CliInstallerProviderStatusMode
|
||||
): Promise<void> {
|
||||
resetGatherDiag(diag);
|
||||
const shellEnvStartedAt = Date.now();
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
diag.shellEnvMs = Date.now() - shellEnvStartedAt;
|
||||
if (providerStatusMode === 'defer') {
|
||||
diag.shellEnvMs = 0;
|
||||
} else {
|
||||
const shellEnvStartedAt = Date.now();
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
});
|
||||
diag.shellEnvMs = Date.now() - shellEnvStartedAt;
|
||||
}
|
||||
|
||||
const r = ref.current;
|
||||
const binaryResolveStartedAt = Date.now();
|
||||
|
|
@ -1048,6 +1058,14 @@ export class CliInstallerService {
|
|||
r.installedVersion = versionProbe.version;
|
||||
r.launchError = null;
|
||||
r.authStatusChecking = true;
|
||||
|
||||
if (r.flavor === 'agent_teams_orchestrator' && providerStatusMode === 'defer') {
|
||||
r.authStatusChecking = false;
|
||||
this.markProvidersDeferred(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
return;
|
||||
}
|
||||
|
||||
this.rememberHealthyStatus(r);
|
||||
this.publishStatusSnapshotIfCurrent(r, generation);
|
||||
|
||||
|
|
@ -1186,6 +1204,28 @@ export class CliInstallerService {
|
|||
result.authMethod = null;
|
||||
}
|
||||
|
||||
private markProvidersDeferred(result: CliInstallationStatus): void {
|
||||
if (result.flavor !== 'agent_teams_orchestrator') {
|
||||
return;
|
||||
}
|
||||
|
||||
result.providers = result.providers.map((provider) => ({
|
||||
...provider,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
modelVerificationState: 'idle',
|
||||
modelCatalogRefreshState: 'idle',
|
||||
statusMessage: CLI_PROVIDER_STATUS_DEFERRED_MESSAGE,
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
backend: null,
|
||||
}));
|
||||
result.authLoggedIn = false;
|
||||
result.authMethod = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check auth status with retry — covers stale lock files after Ctrl+C interruption.
|
||||
* Wrapped in its own timeout to prevent slow auth from blocking the overall status.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,320 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
const logger = createLogger('Service:CrossPlatformFileChangeSource');
|
||||
|
||||
export type PollingChangeEventType = 'rename' | 'change';
|
||||
|
||||
export interface CloseableWatcher {
|
||||
close: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface WatcherLifecycle {
|
||||
onError: (error: unknown) => void;
|
||||
onClose: () => void;
|
||||
isCurrent: () => boolean;
|
||||
}
|
||||
|
||||
export interface CrossPlatformFileChangeSourceOptions {
|
||||
name: string;
|
||||
pollIntervalMs: number;
|
||||
createWatcher?: (lifecycle: WatcherLifecycle) => Promise<CloseableWatcher> | CloseableWatcher;
|
||||
collectPollSnapshot: () => Promise<Map<string, string>>;
|
||||
emitPolledChange: (eventType: PollingChangeEventType, relativePath: string) => void;
|
||||
isOwnerActive: () => boolean;
|
||||
isWatchLimitError: (error: unknown) => boolean;
|
||||
requestRetry: () => void;
|
||||
onWatcherStartError?: (error: unknown) => void;
|
||||
onWatcherError?: (error: unknown) => void;
|
||||
onPollingError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class CrossPlatformFileChangeSource {
|
||||
private watcher: CloseableWatcher | null = null;
|
||||
private pollingTimer: NodeJS.Timeout | null = null;
|
||||
private pollingGenerationInProgress: number | null = null;
|
||||
private pollingPrimed = false;
|
||||
private pollSnapshot = new Map<string, string>();
|
||||
private closedGeneration: number | null = null;
|
||||
private rejectedGeneration: number | null = null;
|
||||
private generation = 0;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(private readonly options: CrossPlatformFileChangeSourceOptions) {}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.watcher !== null || this.pollingTimer !== null;
|
||||
}
|
||||
|
||||
get currentPollingTimer(): NodeJS.Timeout | null {
|
||||
return this.pollingTimer;
|
||||
}
|
||||
|
||||
get isPollingPrimed(): boolean {
|
||||
return this.pollingPrimed;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.startPromise) {
|
||||
await this.startPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const createWatcher = this.options.createWatcher;
|
||||
if (!createWatcher) {
|
||||
this.startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = this.nextGeneration();
|
||||
this.closedGeneration = null;
|
||||
this.rejectedGeneration = null;
|
||||
const startPromise = this.startWatcher(generation, createWatcher);
|
||||
this.startPromise = startPromise;
|
||||
try {
|
||||
await startPromise;
|
||||
} finally {
|
||||
if (this.startPromise === startPromise) {
|
||||
this.startPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async startWatcher(
|
||||
generation: number,
|
||||
createWatcher: NonNullable<CrossPlatformFileChangeSourceOptions['createWatcher']>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const watcher = await createWatcher({
|
||||
onError: (error) => this.handleWatcherError(error, generation),
|
||||
onClose: () => this.handleWatcherClose(generation),
|
||||
isCurrent: () => this.isCurrentGeneration(generation),
|
||||
});
|
||||
|
||||
if (!this.isCurrentGeneration(generation)) {
|
||||
await this.closeWatcher(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
this.watcher = watcher;
|
||||
} catch (error) {
|
||||
if (generation !== this.generation || !this.options.isOwnerActive()) {
|
||||
return;
|
||||
}
|
||||
if (this.pollingTimer || this.rejectedGeneration === generation) {
|
||||
return;
|
||||
}
|
||||
if (this.startPollingFallback(error, generation)) {
|
||||
return;
|
||||
}
|
||||
if (this.closedGeneration === generation) {
|
||||
return;
|
||||
}
|
||||
this.rejectedGeneration = generation;
|
||||
this.options.onWatcherStartError?.(error);
|
||||
this.options.requestRetry();
|
||||
}
|
||||
}
|
||||
|
||||
startPolling(): void {
|
||||
if (this.pollingTimer || !this.options.isOwnerActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = this.nextGeneration();
|
||||
this.startPollingForGeneration(generation);
|
||||
}
|
||||
|
||||
private startPollingForGeneration(generation: number): void {
|
||||
if (this.pollingTimer || generation !== this.generation || !this.options.isOwnerActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const watcher = this.watcher;
|
||||
this.watcher = null;
|
||||
|
||||
const runPoll = (): void => {
|
||||
void this.pollOnce(generation);
|
||||
};
|
||||
|
||||
this.pollingTimer = setInterval(runPoll, this.options.pollIntervalMs);
|
||||
this.pollingTimer.unref();
|
||||
runPoll();
|
||||
|
||||
if (watcher) {
|
||||
void this.closeWatcher(watcher);
|
||||
}
|
||||
}
|
||||
|
||||
async pollOnce(expectedGeneration = this.generation): Promise<void> {
|
||||
if (
|
||||
expectedGeneration !== this.generation ||
|
||||
!this.options.isOwnerActive() ||
|
||||
this.pollingGenerationInProgress !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollingGenerationInProgress = expectedGeneration;
|
||||
try {
|
||||
await this.pollForChanges(expectedGeneration);
|
||||
} catch (error) {
|
||||
if (expectedGeneration === this.generation && this.options.isOwnerActive()) {
|
||||
this.options.onPollingError?.(error);
|
||||
}
|
||||
} finally {
|
||||
if (this.pollingGenerationInProgress === expectedGeneration) {
|
||||
this.pollingGenerationInProgress = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.generation += 1;
|
||||
this.startPromise = null;
|
||||
this.closedGeneration = null;
|
||||
this.rejectedGeneration = null;
|
||||
this.pollingGenerationInProgress = null;
|
||||
this.pollingPrimed = false;
|
||||
this.pollSnapshot.clear();
|
||||
|
||||
const timer = this.pollingTimer;
|
||||
this.pollingTimer = null;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
|
||||
const watcher = this.watcher;
|
||||
this.watcher = null;
|
||||
if (watcher) {
|
||||
void this.closeWatcher(watcher);
|
||||
}
|
||||
}
|
||||
|
||||
private handleWatcherError(error: unknown, generation: number): void {
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
!this.options.isOwnerActive() ||
|
||||
this.rejectedGeneration === generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.startPollingFallback(error, generation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.closedGeneration === generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rejectedGeneration = generation;
|
||||
this.options.onWatcherError?.(error);
|
||||
const watcher = this.watcher;
|
||||
this.watcher = null;
|
||||
if (watcher) {
|
||||
void this.closeWatcher(watcher);
|
||||
}
|
||||
if (!this.isActive) {
|
||||
this.options.requestRetry();
|
||||
}
|
||||
}
|
||||
|
||||
private handleWatcherClose(generation: number): void {
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
!this.options.isOwnerActive() ||
|
||||
this.closedGeneration === generation ||
|
||||
this.rejectedGeneration === generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closedGeneration = generation;
|
||||
this.watcher = null;
|
||||
if (!this.isActive) {
|
||||
this.options.requestRetry();
|
||||
}
|
||||
}
|
||||
|
||||
private startPollingFallback(error: unknown, generation: number): boolean {
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
!this.options.isOwnerActive() ||
|
||||
!this.options.isWatchLimitError(error)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.rejectedGeneration = generation;
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
logger.warn(
|
||||
`${this.options.name} watcher hit ${err.code ?? 'a platform limit'}; falling back to polling`
|
||||
);
|
||||
|
||||
const watcher = this.watcher;
|
||||
this.watcher = null;
|
||||
this.startPollingForGeneration(generation);
|
||||
if (watcher) {
|
||||
void this.closeWatcher(watcher);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async pollForChanges(expectedGeneration: number): Promise<void> {
|
||||
const nextSnapshot = await this.options.collectPollSnapshot();
|
||||
if (expectedGeneration !== this.generation || !this.options.isOwnerActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.pollingPrimed) {
|
||||
logger.info(`${this.options.name} polling baseline captured`);
|
||||
this.pollSnapshot = nextSnapshot;
|
||||
this.pollingPrimed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [relativePath, fingerprint] of nextSnapshot) {
|
||||
const previous = this.pollSnapshot.get(relativePath);
|
||||
if (previous === undefined) {
|
||||
this.options.emitPolledChange('rename', relativePath);
|
||||
} else if (previous !== fingerprint) {
|
||||
this.options.emitPolledChange('change', relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const relativePath of this.pollSnapshot.keys()) {
|
||||
if (!nextSnapshot.has(relativePath)) {
|
||||
this.options.emitPolledChange('rename', relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
this.pollSnapshot = nextSnapshot;
|
||||
}
|
||||
|
||||
private async closeWatcher(watcher: CloseableWatcher): Promise<void> {
|
||||
try {
|
||||
await watcher.close();
|
||||
} catch (error) {
|
||||
logger.debug(`${this.options.name} watcher close failed`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private nextGeneration(): number {
|
||||
this.generation += 1;
|
||||
return this.generation;
|
||||
}
|
||||
|
||||
private isCurrentGeneration(generation: number): boolean {
|
||||
return (
|
||||
generation === this.generation &&
|
||||
this.options.isOwnerActive() &&
|
||||
!this.pollingTimer &&
|
||||
this.closedGeneration !== generation &&
|
||||
this.rejectedGeneration !== generation
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -212,8 +212,13 @@ async function probeFirstWorkingOpenCodeBinaryCandidate(
|
|||
return { ok: false, firstFailure: nextFirstFailure };
|
||||
}
|
||||
|
||||
interface OpenCodeRuntimeBinaryResolveOptions {
|
||||
shellEnvTimeoutMs?: number;
|
||||
includeShellEnv?: boolean;
|
||||
}
|
||||
|
||||
async function probeFirstWorkingPathOpenCodeBinary(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||
): Promise<VerifiedOpenCodeBinaryProbe> {
|
||||
const seenCandidates = new Set<string>();
|
||||
let firstFailure: { binaryPath: string; error: string } | null = null;
|
||||
|
|
@ -230,6 +235,16 @@ async function probeFirstWorkingPathOpenCodeBinary(
|
|||
}
|
||||
firstFailure = cachedProbe.firstFailure;
|
||||
|
||||
if (options.includeShellEnv === false) {
|
||||
return probeFirstWorkingOpenCodeBinaryCandidate(
|
||||
collectPathOpenCodeBinaryCandidates([], {
|
||||
includeFallbackPathEntries: true,
|
||||
}),
|
||||
seenCandidates,
|
||||
firstFailure
|
||||
);
|
||||
}
|
||||
|
||||
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
|
||||
fallbackEnv: process.env,
|
||||
|
|
@ -254,14 +269,14 @@ async function probeFirstWorkingPathOpenCodeBinary(
|
|||
}
|
||||
|
||||
async function resolveVerifiedPathOpenCodeBinaryPath(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||
): Promise<string | null> {
|
||||
const result = await probeFirstWorkingPathOpenCodeBinary(options);
|
||||
return result.ok ? result.binaryPath : null;
|
||||
}
|
||||
|
||||
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
options: OpenCodeRuntimeBinaryResolveOptions = {}
|
||||
): Promise<string | null> {
|
||||
return (
|
||||
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const RECONCILE_INTERVAL_MS = 30_000;
|
|||
// If a new team artifact should produce TeamChangeEvent, add it here too.
|
||||
const TEAM_ROOT_FILES = new Set([
|
||||
'config.json',
|
||||
'kanban-state.json',
|
||||
'processes.json',
|
||||
'sentMessages.json',
|
||||
'team.meta.json',
|
||||
|
|
@ -62,7 +63,7 @@ export class TeamTaskWatchRegistry {
|
|||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
await this.reconcileTargets();
|
||||
await this.reconcileTargets({ rethrowErrors: true });
|
||||
if (this.closed || this.reconcileTimer) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -89,11 +90,11 @@ export class TeamTaskWatchRegistry {
|
|||
this.targets.clear();
|
||||
this.targetKey = '';
|
||||
if (watcher) {
|
||||
await watcher.close().catch(() => undefined);
|
||||
await this.closeWatcher(watcher);
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcileTargets(): Promise<void> {
|
||||
private async reconcileTargets(options: { rethrowErrors?: boolean } = {}): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -111,6 +112,9 @@ export class TeamTaskWatchRegistry {
|
|||
await this.rebuildWatcher(targets, nextKey, addedTargets);
|
||||
}
|
||||
} catch (error) {
|
||||
if (options.rethrowErrors) {
|
||||
throw error;
|
||||
}
|
||||
if (!this.closed) {
|
||||
this.options.onError(error);
|
||||
}
|
||||
|
|
@ -135,7 +139,7 @@ export class TeamTaskWatchRegistry {
|
|||
const previousWatcher = this.watcher;
|
||||
this.watcher = null;
|
||||
if (previousWatcher) {
|
||||
await previousWatcher.close().catch(() => undefined);
|
||||
await this.closeWatcher(previousWatcher);
|
||||
}
|
||||
|
||||
if (this.closed || generation !== this.generation) {
|
||||
|
|
@ -264,6 +268,15 @@ export class TeamTaskWatchRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
private async closeWatcher(watcher: FSWatcher): Promise<void> {
|
||||
try {
|
||||
await watcher.close();
|
||||
} catch {
|
||||
// Best-effort cleanup only. Chokidar close can fail if the underlying
|
||||
// watcher is already torn down during startup or limit-error recovery.
|
||||
}
|
||||
}
|
||||
|
||||
private async isDirectory(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fsp.stat(dirPath)).isDirectory();
|
||||
|
|
|
|||
|
|
@ -302,6 +302,19 @@ export class ClaudeBinaryResolver {
|
|||
}
|
||||
}
|
||||
|
||||
const shouldTryBundledOrchestratorBeforeShell =
|
||||
flavor === 'agent_teams_orchestrator' && (!overrideRaw || overrideIsExplicitPath);
|
||||
if (shouldTryBundledOrchestratorBeforeShell) {
|
||||
emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
|
||||
const bundledBinary = await resolveBundledOrchestratorBinary();
|
||||
if (bundledBinary) {
|
||||
cachedPath = bundledBinary;
|
||||
cacheVerifiedAt = Date.now();
|
||||
emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
|
||||
return cachedPath;
|
||||
}
|
||||
}
|
||||
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
|
|
@ -323,13 +336,15 @@ export class ClaudeBinaryResolver {
|
|||
}
|
||||
|
||||
if (flavor === 'agent_teams_orchestrator') {
|
||||
emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
|
||||
const bundledBinary = await resolveBundledOrchestratorBinary();
|
||||
if (bundledBinary) {
|
||||
cachedPath = bundledBinary;
|
||||
cacheVerifiedAt = Date.now();
|
||||
emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
|
||||
return cachedPath;
|
||||
if (!shouldTryBundledOrchestratorBeforeShell) {
|
||||
emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
|
||||
const bundledBinary = await resolveBundledOrchestratorBinary();
|
||||
if (bundledBinary) {
|
||||
cachedPath = bundledBinary;
|
||||
cacheVerifiedAt = Date.now();
|
||||
emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
|
||||
return cachedPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep agent_teams_orchestrator resolution generic. Dev flows should
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ const MAX_CLI_LOG_CHARS = 256_000;
|
|||
const MAX_TRACE_CHARS = 128_000;
|
||||
const MAX_COPIED_FILE_BYTES = 256 * 1024;
|
||||
const MAX_DIAGNOSTICS_COPY_FILE_BYTES = 128 * 1024;
|
||||
const RUNTIME_ARTIFACT_FILE_PATTERN = /^[^/\\]+(?:\.runtime\.jsonl|\.stdout\.log|\.stderr\.log)$/;
|
||||
const RUNTIME_ARTIFACT_LABEL_PATTERN =
|
||||
/^runtime\/[^/\\]+(?:\.runtime\.jsonl|\.stdout\.log|\.stderr\.log)$/;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
|
|
@ -63,6 +66,7 @@ export type LaunchFailureArtifactClassificationCode =
|
|||
| 'stdin_missing'
|
||||
| 'provider_quota'
|
||||
| 'provider_auth'
|
||||
| 'process_readiness_timeout'
|
||||
| 'model_no_bootstrap'
|
||||
| 'process_exited'
|
||||
| 'opencode_protocol'
|
||||
|
|
@ -289,6 +293,12 @@ export function classifyLaunchFailureArtifact(
|
|||
pattern:
|
||||
/401 unauthorized|not_logged_in|login required|auth(?:entication)? failed|api key.*(?:missing|invalid)|token refresh failed/i,
|
||||
},
|
||||
{
|
||||
code: 'process_readiness_timeout',
|
||||
confidence: 0.9,
|
||||
pattern:
|
||||
/did not become (?:runtime_ready|inbox_poller_ready)|timed out waiting for (?:runtime_ready|inbox_poller_ready)/i,
|
||||
},
|
||||
{
|
||||
code: 'opencode_protocol',
|
||||
confidence: 0.84,
|
||||
|
|
@ -431,6 +441,15 @@ function getRecord(value: unknown): JsonRecord | null {
|
|||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : null;
|
||||
}
|
||||
|
||||
function getRuntimeArtifactLabels(manifestJson: JsonRecord | null): string[] {
|
||||
const artifactFiles = manifestJson?.artifactFiles;
|
||||
if (!Array.isArray(artifactFiles)) return [];
|
||||
return artifactFiles
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.filter((item) => RUNTIME_ARTIFACT_LABEL_PATTERN.test(item))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function resolveArtifactManifestPath(
|
||||
teamDir: string,
|
||||
latestJson: JsonRecord | null,
|
||||
|
|
@ -489,6 +508,18 @@ export async function readTeamLaunchFailureDiagnosticsBundle(
|
|||
});
|
||||
}
|
||||
|
||||
if (resolvedManifest.path) {
|
||||
const artifactDirectory = path.dirname(resolvedManifest.path);
|
||||
for (const artifactName of getRuntimeArtifactLabels(manifestJson)) {
|
||||
files.push(
|
||||
await readDiagnosticsCopyFile(
|
||||
`launch-failure-artifacts/${artifactName}`,
|
||||
path.join(artifactDirectory, artifactName)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
files.push(
|
||||
await readDiagnosticsCopyFile(
|
||||
'bootstrap-journal.jsonl',
|
||||
|
|
@ -559,10 +590,32 @@ export async function readTeamLaunchFailureDiagnosticsBundle(
|
|||
};
|
||||
}
|
||||
|
||||
function getKnownLaunchArtifactSourceFiles(teamName: string): CopiedArtifactFile[] {
|
||||
async function getRuntimeLaunchArtifactSourceFiles(teamDir: string): Promise<CopiedArtifactFile[]> {
|
||||
const runtimeDir = path.join(teamDir, 'runtime');
|
||||
try {
|
||||
const entries = await fs.promises.readdir(runtimeDir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && RUNTIME_ARTIFACT_FILE_PATTERN.test(entry.name))
|
||||
.map((entry) => ({
|
||||
sourcePath: path.join(runtimeDir, entry.name),
|
||||
artifactName: `runtime/${entry.name}`,
|
||||
}))
|
||||
.sort((left, right) => left.artifactName.localeCompare(right.artifactName));
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') return [];
|
||||
logger.warn('[ArtifactPack] Failed to enumerate runtime artifacts', {
|
||||
teamDir,
|
||||
error: String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getKnownLaunchArtifactSourceFiles(teamName: string): Promise<CopiedArtifactFile[]> {
|
||||
const bootstrapStatePath = getTeamBootstrapStatePath(teamName);
|
||||
const teamDir = path.dirname(bootstrapStatePath);
|
||||
return [
|
||||
const launchFiles: CopiedArtifactFile[] = [
|
||||
{
|
||||
sourcePath: getTeamLaunchStatePath(teamName),
|
||||
artifactName: 'launch-state.json',
|
||||
|
|
@ -584,6 +637,7 @@ function getKnownLaunchArtifactSourceFiles(teamName: string): CopiedArtifactFile
|
|||
artifactName: 'bootstrap-lock-metadata.json',
|
||||
},
|
||||
];
|
||||
return [...launchFiles, ...(await getRuntimeLaunchArtifactSourceFiles(teamDir))];
|
||||
}
|
||||
|
||||
async function writeArtifactTextFile(
|
||||
|
|
@ -593,6 +647,8 @@ async function writeArtifactTextFile(
|
|||
files: string[]
|
||||
): Promise<void> {
|
||||
const targetPath = path.join(directory, artifactName);
|
||||
assertPathWithin(directory, targetPath);
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await atomicWriteAsync(targetPath, `${redactLaunchFailureArtifactText(rawText).trimEnd()}\n`);
|
||||
files.push(artifactName);
|
||||
}
|
||||
|
|
@ -639,7 +695,7 @@ export async function writeTeamLaunchFailureArtifactPack(
|
|||
);
|
||||
}
|
||||
|
||||
for (const source of getKnownLaunchArtifactSourceFiles(input.teamName)) {
|
||||
for (const source of await getKnownLaunchArtifactSourceFiles(input.teamName)) {
|
||||
const read = await readBoundedTextFile(source.sourcePath);
|
||||
if (read.text !== undefined) {
|
||||
await writeArtifactTextFile(directory, source.artifactName, read.text, files);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
|
|||
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
|
||||
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
|
||||
const ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
|
||||
const MIN_MCP_NODE_MAJOR_VERSION = 20;
|
||||
const NODE_RUNTIME_PROBE_SCRIPT =
|
||||
'process.stdout.write(JSON.stringify({execPath:process.execPath,version:process.versions.node}))';
|
||||
/**
|
||||
* Stale configs older than this are removed on startup (best-effort).
|
||||
* 7 days is intentionally long: respawnAfterAuthFailure() reuses saved
|
||||
|
|
@ -57,6 +60,11 @@ const MCP_CONFIG_STALE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|||
|
||||
type McpServerConfig = Record<string, unknown>;
|
||||
|
||||
interface NodeRuntimeProbeMetadata {
|
||||
path: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'project', 'local'];
|
||||
|
||||
function isPackagedApp(): boolean {
|
||||
|
|
@ -284,6 +292,56 @@ function mergePathValues(...values: (string | undefined)[]): string | undefined
|
|||
return merged.length > 0 ? merged.join(path.delimiter) : undefined;
|
||||
}
|
||||
|
||||
function parseNodeMajorVersion(version: string): number | null {
|
||||
const match = /^v?(\d+)(?:\.|$)/.exec(version.trim());
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const major = Number.parseInt(match[1] ?? '', 10);
|
||||
return Number.isFinite(major) ? major : null;
|
||||
}
|
||||
|
||||
function parseNodeRuntimeProbeMetadata(stdout: string, command: string): NodeRuntimeProbeMetadata {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${command} did not report Node.js runtime metadata`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error(`${command} reported invalid Node.js runtime metadata`);
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object') {
|
||||
throw new Error(`${command} reported invalid Node.js runtime metadata`);
|
||||
}
|
||||
|
||||
const metadata = parsed as { execPath?: unknown; version?: unknown };
|
||||
const resolvedPath = typeof metadata.execPath === 'string' ? metadata.execPath.trim() : '';
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`${command} did not report process.execPath`);
|
||||
}
|
||||
|
||||
const version = typeof metadata.version === 'string' ? metadata.version.trim() : '';
|
||||
if (!version) {
|
||||
throw new Error(`${command} did not report process.versions.node`);
|
||||
}
|
||||
|
||||
return { path: resolvedPath, version };
|
||||
}
|
||||
|
||||
function assertSupportedMcpNodeRuntime(command: string, metadata: NodeRuntimeProbeMetadata): void {
|
||||
const major = parseNodeMajorVersion(metadata.version);
|
||||
if (major === null || major < MIN_MCP_NODE_MAJOR_VERSION) {
|
||||
throw new Error(
|
||||
`${command} resolved ${metadata.path} with Node.js ${metadata.version}; Agent Teams MCP requires Node.js ${MIN_MCP_NODE_MAJOR_VERSION}+`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isWriteMcpConfigOptions(value: unknown): value is WriteMcpConfigOptions {
|
||||
return (
|
||||
value !== null &&
|
||||
|
|
@ -310,16 +368,14 @@ async function probeNodeRuntimePath(
|
|||
let lastError: unknown = null;
|
||||
for (const command of getNodeRuntimeCommandCandidates()) {
|
||||
try {
|
||||
const { stdout } = await execCli(command, ['-e', 'process.stdout.write(process.execPath)'], {
|
||||
const { stdout } = await execCli(command, ['-e', NODE_RUNTIME_PROBE_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS,
|
||||
env,
|
||||
});
|
||||
const resolved = stdout.trim();
|
||||
if (!resolved) {
|
||||
throw new Error(`${command} did not report process.execPath`);
|
||||
}
|
||||
return { ok: true, path: resolved };
|
||||
const metadata = parseNodeRuntimeProbeMetadata(stdout, command);
|
||||
assertSupportedMcpNodeRuntime(command, metadata);
|
||||
return { ok: true, path: metadata.path };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
|
@ -386,7 +442,9 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
|||
if (_resolvedNodePath) return _resolvedNodePath;
|
||||
|
||||
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
||||
if (shouldPreferShellNodeProbe()) {
|
||||
const preferShellNodeProbe = shouldPreferShellNodeProbe();
|
||||
if (preferShellNodeProbe && !process.env.NODE_BINARY?.trim()) {
|
||||
emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
|
||||
const shellProbe = await probeShellNodeRuntimePath(options);
|
||||
if (shellProbe.ok) {
|
||||
_resolvedNodePath = shellProbe.path;
|
||||
|
|
@ -402,18 +460,28 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
|
|||
return _resolvedNodePath;
|
||||
}
|
||||
|
||||
const shellProbe = await probeShellNodeRuntimePath(options);
|
||||
if (shellProbe.ok) {
|
||||
_resolvedNodePath = shellProbe.path;
|
||||
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
||||
return _resolvedNodePath;
|
||||
if (!preferShellNodeProbe) {
|
||||
emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
|
||||
const shellProbe = await probeShellNodeRuntimePath(options);
|
||||
if (shellProbe.ok) {
|
||||
_resolvedNodePath = shellProbe.path;
|
||||
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
||||
return _resolvedNodePath;
|
||||
}
|
||||
|
||||
emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
|
||||
throw new Error(
|
||||
`Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${
|
||||
shellProbe.error ? stringifyError(shellProbe.error) : stringifyError(fastProbe.error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
|
||||
throw new Error(
|
||||
`Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${
|
||||
shellProbe.error ? stringifyError(shellProbe.error) : stringifyError(fastProbe.error)
|
||||
}`
|
||||
`Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${stringifyError(
|
||||
fastProbe.error
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ import type {
|
|||
ClaudeRootFolderSelection,
|
||||
ClaudeRootInfo,
|
||||
CliInstallationStatus,
|
||||
CliInstallerGetStatusOptions,
|
||||
CliInstallerProgress,
|
||||
CliProviderId,
|
||||
ConflictCheckResult,
|
||||
|
|
@ -1554,8 +1555,8 @@ const electronAPI: ElectronAPI = {
|
|||
|
||||
// ===== CLI Installer API =====
|
||||
cliInstaller: {
|
||||
getStatus: async (): Promise<CliInstallationStatus> => {
|
||||
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
|
||||
getStatus: async (options?: CliInstallerGetStatusOptions): Promise<CliInstallationStatus> => {
|
||||
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS, options);
|
||||
},
|
||||
getProviderStatus: async (providerId: CliProviderId) => {
|
||||
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ declare global {
|
|||
interface Window {
|
||||
__claudeTeamsSplashEnhancedStartedAt?: number;
|
||||
__claudeTeamsSplashScene?: SplashSceneHandle;
|
||||
__claudeTeamsSplashEnhancedDisabled?: boolean;
|
||||
__claudeTeamsSplashStartedAt?: number;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +39,11 @@ export const App = (): React.JSX.Element => {
|
|||
const splash = document.getElementById('splash');
|
||||
if (splash) {
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const scene = window.__claudeTeamsSplashScene ?? startSplashScene(splash, { reducedMotion });
|
||||
const scene: SplashSceneHandle =
|
||||
window.__claudeTeamsSplashScene ??
|
||||
(window.__claudeTeamsSplashEnhancedDisabled
|
||||
? { stop: () => undefined, ready: Promise.resolve() }
|
||||
: startSplashScene(splash, { reducedMotion }));
|
||||
const startedAt = window.__claudeTeamsSplashStartedAt ?? performance.now();
|
||||
const enhancedStartedAt = window.__claudeTeamsSplashEnhancedStartedAt ?? performance.now();
|
||||
const elapsed = performance.now() - startedAt;
|
||||
|
|
@ -62,6 +67,7 @@ export const App = (): React.JSX.Element => {
|
|||
scene.stop();
|
||||
window.__claudeTeamsSplashScene = undefined;
|
||||
window.__claudeTeamsSplashEnhancedStartedAt = undefined;
|
||||
window.__claudeTeamsSplashEnhancedDisabled = undefined;
|
||||
splash.remove();
|
||||
}, fadeDuration);
|
||||
};
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 992 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 68 KiB |
|
|
@ -1,85 +1,17 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
|
||||
mergeCodexCliStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { ProviderBrandLogo } from './ProviderBrandLogo';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
interface ProviderActivityState {
|
||||
provider: CliProviderStatus;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
(!provider.authenticated &&
|
||||
provider.statusMessage === 'Checking...' &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldMaskCodexNegativeBootstrapState(
|
||||
sourceProvider: CliProviderStatus | null,
|
||||
mergedProvider: CliProviderStatus
|
||||
): boolean {
|
||||
return (
|
||||
sourceProvider?.providerId === 'codex' &&
|
||||
sourceProvider.statusMessage === 'Checking...' &&
|
||||
mergedProvider.providerId === 'codex' &&
|
||||
mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' &&
|
||||
mergedProvider.connection.codex.login.status === 'idle'
|
||||
);
|
||||
}
|
||||
|
||||
function getActivityToneStyles(tone: 'loading' | 'checked' | 'error'): {
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
statusColor: string;
|
||||
} {
|
||||
switch (tone) {
|
||||
case 'checked':
|
||||
return {
|
||||
borderColor: 'rgba(34, 197, 94, 0.22)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.08)',
|
||||
textColor: '#dcfce7',
|
||||
statusColor: '#86efac',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
borderColor: 'rgba(239, 68, 68, 0.28)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||
textColor: '#fee2e2',
|
||||
statusColor: '#fca5a5',
|
||||
};
|
||||
case 'loading':
|
||||
default:
|
||||
return {
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
textColor: 'var(--color-text-secondary)',
|
||||
statusColor: 'var(--color-text-muted)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function areProviderIdListsEqual(nextIds: CliProviderId[], prevIds: CliProviderId[]): boolean {
|
||||
return nextIds.length === prevIds.length && nextIds.every((id, index) => prevIds[index] === id);
|
||||
}
|
||||
import { ProviderActivityStatusStrip } from './ProviderActivityStatusStrip';
|
||||
|
||||
export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
|
|
@ -106,7 +38,6 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
|||
};
|
||||
})
|
||||
);
|
||||
const [cycleProviderIds, setCycleProviderIds] = useState<CliProviderId[]>([]);
|
||||
|
||||
const loadingCliStatus = useMemo(
|
||||
() =>
|
||||
|
|
@ -123,6 +54,8 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
|||
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
||||
includeRateLimits: false,
|
||||
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
|
||||
initialRefreshMaxDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
|
||||
});
|
||||
|
||||
const effectiveCliStatus = useMemo(
|
||||
|
|
@ -134,177 +67,19 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
|
|||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
|
||||
!codexAccount.snapshot;
|
||||
|
||||
const sourceProviderMap = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(loadingCliStatus?.providers ?? []).map((provider) => [provider.providerId, provider])
|
||||
),
|
||||
[loadingCliStatus?.providers]
|
||||
);
|
||||
|
||||
const providerStates = useMemo<ProviderActivityState[]>(() => {
|
||||
const visibleProviders = filterMainScreenCliProviders(effectiveCliStatus?.providers ?? []);
|
||||
|
||||
return visibleProviders.map((provider) => {
|
||||
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
|
||||
const loading =
|
||||
isProviderCardLoading(provider, cliProviderStatusLoading[provider.providerId] === true) ||
|
||||
(provider.providerId === 'codex' && codexSnapshotPending) ||
|
||||
shouldMaskCodexNegativeBootstrapState(sourceProvider, provider);
|
||||
|
||||
return {
|
||||
provider,
|
||||
loading,
|
||||
error: !loading && provider.verificationState === 'error',
|
||||
};
|
||||
});
|
||||
}, [
|
||||
cliProviderStatusLoading,
|
||||
codexSnapshotPending,
|
||||
effectiveCliStatus?.providers,
|
||||
sourceProviderMap,
|
||||
]);
|
||||
|
||||
const visibleProviderIds = useMemo(
|
||||
() => providerStates.map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const loadingProviderIds = useMemo(
|
||||
() => providerStates.filter((state) => state.loading).map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const errorProviderIds = useMemo(
|
||||
() => providerStates.filter((state) => state.error).map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const providerStateMap = useMemo(
|
||||
() => new Map(providerStates.map((state) => [state.provider.providerId, state])),
|
||||
[providerStates]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCycleProviderIds((previousIds) => {
|
||||
const visiblePreviousIds = previousIds.filter((providerId) =>
|
||||
visibleProviderIds.includes(providerId)
|
||||
);
|
||||
|
||||
if (loadingProviderIds.length > 0) {
|
||||
const nextIds = [...visiblePreviousIds];
|
||||
for (const providerId of loadingProviderIds) {
|
||||
if (!nextIds.includes(providerId)) {
|
||||
nextIds.push(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
return areProviderIdListsEqual(nextIds, previousIds) ? previousIds : nextIds;
|
||||
}
|
||||
|
||||
if (errorProviderIds.length > 0) {
|
||||
return areProviderIdListsEqual(errorProviderIds, previousIds)
|
||||
? previousIds
|
||||
: errorProviderIds;
|
||||
}
|
||||
|
||||
return previousIds.length === 0 ? previousIds : [];
|
||||
});
|
||||
}, [errorProviderIds, loadingProviderIds, visibleProviderIds]);
|
||||
|
||||
const displayProviderIds = useMemo(() => {
|
||||
if (loadingProviderIds.length > 0) {
|
||||
const activeCycleIds = (
|
||||
cycleProviderIds.length > 0 ? cycleProviderIds : loadingProviderIds
|
||||
).filter((providerId) => providerStateMap.has(providerId));
|
||||
return Array.from(new Set([...activeCycleIds, ...errorProviderIds]));
|
||||
}
|
||||
|
||||
if (errorProviderIds.length > 0) {
|
||||
return errorProviderIds;
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [cycleProviderIds, errorProviderIds, loadingProviderIds, providerStateMap]);
|
||||
|
||||
if (
|
||||
!isElectron ||
|
||||
isDashboardFocused ||
|
||||
!multimodelEnabled ||
|
||||
effectiveCliStatus?.flavor !== 'agent_teams_orchestrator' ||
|
||||
!effectiveCliStatus.installed ||
|
||||
displayProviderIds.length === 0
|
||||
) {
|
||||
if (isDashboardFocused) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-sidebar)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 text-[11px] font-medium uppercase tracking-[0.08em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
Provider Activity
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{displayProviderIds.map((providerId) => {
|
||||
const providerState = providerStateMap.get(providerId);
|
||||
if (!providerState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tone = providerState.loading
|
||||
? 'loading'
|
||||
: providerState.error
|
||||
? 'error'
|
||||
: 'checked';
|
||||
const styles = getActivityToneStyles(tone);
|
||||
const statusText =
|
||||
tone === 'loading'
|
||||
? 'Checking...'
|
||||
: tone === 'error'
|
||||
? formatProviderStatusText(providerState.provider)
|
||||
: 'Checked';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={providerId}
|
||||
data-testid={`global-provider-status-${providerId}`}
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: styles.borderColor,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
color: styles.textColor,
|
||||
}}
|
||||
>
|
||||
{tone === 'loading' ? (
|
||||
<Loader2
|
||||
className="size-3 shrink-0 animate-spin"
|
||||
style={{ color: styles.statusColor }}
|
||||
/>
|
||||
) : tone === 'error' ? (
|
||||
<AlertTriangle className="size-3 shrink-0" style={{ color: styles.statusColor }} />
|
||||
) : (
|
||||
<CheckCircle2 className="size-3 shrink-0" style={{ color: styles.statusColor }} />
|
||||
)}
|
||||
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0 font-medium" style={{ color: styles.textColor }}>
|
||||
{providerState.provider.displayName}
|
||||
</span>
|
||||
<span
|
||||
className="max-w-[280px] truncate"
|
||||
style={{ color: styles.statusColor }}
|
||||
title={statusText}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ProviderActivityStatusStrip
|
||||
cliStatus={effectiveCliStatus}
|
||||
sourceCliStatus={loadingCliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-sidebar)] px-4 py-2"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
323
src/renderer/components/common/ProviderActivityStatusStrip.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import { ProviderBrandLogo } from './ProviderBrandLogo';
|
||||
|
||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
interface ProviderActivityState {
|
||||
provider: CliProviderStatus;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
interface ProviderActivityStatusStripProps {
|
||||
readonly cliStatus: CliInstallationStatus | null | undefined;
|
||||
readonly sourceCliStatus?: CliInstallationStatus | null;
|
||||
readonly cliStatusLoading: boolean;
|
||||
readonly cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||
readonly multimodelEnabled: boolean;
|
||||
readonly codexSnapshotPending?: boolean;
|
||||
readonly providerIds?: readonly CliProviderId[];
|
||||
readonly className?: string;
|
||||
readonly label?: string | null;
|
||||
}
|
||||
|
||||
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
(!provider.authenticated &&
|
||||
(provider.statusMessage === 'Checking...' ||
|
||||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldMaskCodexNegativeBootstrapState(
|
||||
sourceProvider: CliProviderStatus | null,
|
||||
mergedProvider: CliProviderStatus
|
||||
): boolean {
|
||||
return (
|
||||
sourceProvider?.providerId === 'codex' &&
|
||||
sourceProvider.statusMessage === 'Checking...' &&
|
||||
mergedProvider.providerId === 'codex' &&
|
||||
mergedProvider.connection?.codex?.launchReadinessState === 'missing_auth' &&
|
||||
mergedProvider.connection.codex.login.status === 'idle'
|
||||
);
|
||||
}
|
||||
|
||||
function getActivityToneStyles(tone: 'loading' | 'checked' | 'error'): {
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
statusColor: string;
|
||||
} {
|
||||
switch (tone) {
|
||||
case 'checked':
|
||||
return {
|
||||
borderColor: 'rgba(34, 197, 94, 0.22)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.08)',
|
||||
textColor: '#dcfce7',
|
||||
statusColor: '#86efac',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
borderColor: 'rgba(239, 68, 68, 0.28)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||
textColor: '#fee2e2',
|
||||
statusColor: '#fca5a5',
|
||||
};
|
||||
case 'loading':
|
||||
default:
|
||||
return {
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
textColor: 'var(--color-text-secondary)',
|
||||
statusColor: 'var(--color-text-muted)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function areProviderIdListsEqual(nextIds: CliProviderId[], prevIds: CliProviderId[]): boolean {
|
||||
return nextIds.length === prevIds.length && nextIds.every((id, index) => prevIds[index] === id);
|
||||
}
|
||||
|
||||
function useProviderActivityDisplay({
|
||||
cliStatus,
|
||||
sourceCliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
multimodelEnabled,
|
||||
codexSnapshotPending = false,
|
||||
providerIds,
|
||||
}: Pick<
|
||||
ProviderActivityStatusStripProps,
|
||||
| 'cliStatus'
|
||||
| 'sourceCliStatus'
|
||||
| 'cliStatusLoading'
|
||||
| 'cliProviderStatusLoading'
|
||||
| 'multimodelEnabled'
|
||||
| 'codexSnapshotPending'
|
||||
| 'providerIds'
|
||||
>): {
|
||||
displayProviderIds: CliProviderId[];
|
||||
providerStateMap: Map<CliProviderId, ProviderActivityState>;
|
||||
shouldRender: boolean;
|
||||
} {
|
||||
const [cycleProviderIds, setCycleProviderIds] = useState<CliProviderId[]>([]);
|
||||
const renderCliStatus = useMemo(
|
||||
() =>
|
||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||
? createLoadingMultimodelCliStatus()
|
||||
: (cliStatus ?? null),
|
||||
[cliStatus, cliStatusLoading, multimodelEnabled]
|
||||
);
|
||||
const sourceStatus = sourceCliStatus ?? renderCliStatus;
|
||||
const providerIdSet = useMemo(
|
||||
() => (providerIds ? new Set<CliProviderId>(providerIds) : null),
|
||||
[providerIds]
|
||||
);
|
||||
const sourceProviderMap = useMemo(
|
||||
() =>
|
||||
new Map((sourceStatus?.providers ?? []).map((provider) => [provider.providerId, provider])),
|
||||
[sourceStatus?.providers]
|
||||
);
|
||||
|
||||
const providerStates = useMemo<ProviderActivityState[]>(() => {
|
||||
const visibleProviders = filterMainScreenCliProviders(renderCliStatus?.providers ?? []).filter(
|
||||
(provider) => !providerIdSet || providerIdSet.has(provider.providerId)
|
||||
);
|
||||
|
||||
return visibleProviders.map((provider) => {
|
||||
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
|
||||
const loading =
|
||||
isProviderCardLoading(provider, cliProviderStatusLoading[provider.providerId] === true) ||
|
||||
(provider.providerId === 'codex' && codexSnapshotPending) ||
|
||||
shouldMaskCodexNegativeBootstrapState(sourceProvider, provider);
|
||||
|
||||
return {
|
||||
provider,
|
||||
loading,
|
||||
error: !loading && provider.verificationState === 'error',
|
||||
};
|
||||
});
|
||||
}, [
|
||||
cliProviderStatusLoading,
|
||||
codexSnapshotPending,
|
||||
providerIdSet,
|
||||
renderCliStatus?.providers,
|
||||
sourceProviderMap,
|
||||
]);
|
||||
|
||||
const visibleProviderIds = useMemo(
|
||||
() => providerStates.map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const loadingProviderIds = useMemo(
|
||||
() => providerStates.filter((state) => state.loading).map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const errorProviderIds = useMemo(
|
||||
() => providerStates.filter((state) => state.error).map((state) => state.provider.providerId),
|
||||
[providerStates]
|
||||
);
|
||||
const providerStateMap = useMemo(
|
||||
() => new Map(providerStates.map((state) => [state.provider.providerId, state])),
|
||||
[providerStates]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCycleProviderIds((previousIds) => {
|
||||
const visiblePreviousIds = previousIds.filter((providerId) =>
|
||||
visibleProviderIds.includes(providerId)
|
||||
);
|
||||
|
||||
if (loadingProviderIds.length > 0) {
|
||||
const nextIds = [...visiblePreviousIds];
|
||||
for (const providerId of loadingProviderIds) {
|
||||
if (!nextIds.includes(providerId)) {
|
||||
nextIds.push(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
return areProviderIdListsEqual(nextIds, previousIds) ? previousIds : nextIds;
|
||||
}
|
||||
|
||||
if (errorProviderIds.length > 0) {
|
||||
return areProviderIdListsEqual(errorProviderIds, previousIds)
|
||||
? previousIds
|
||||
: errorProviderIds;
|
||||
}
|
||||
|
||||
return previousIds.length === 0 ? previousIds : [];
|
||||
});
|
||||
}, [errorProviderIds, loadingProviderIds, visibleProviderIds]);
|
||||
|
||||
const displayProviderIds = useMemo(() => {
|
||||
if (loadingProviderIds.length > 0) {
|
||||
const activeCycleIds = (
|
||||
cycleProviderIds.length > 0 ? cycleProviderIds : loadingProviderIds
|
||||
).filter((providerId) => providerStateMap.has(providerId));
|
||||
return Array.from(new Set([...activeCycleIds, ...errorProviderIds]));
|
||||
}
|
||||
|
||||
if (errorProviderIds.length > 0) {
|
||||
return errorProviderIds;
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [cycleProviderIds, errorProviderIds, loadingProviderIds, providerStateMap]);
|
||||
|
||||
return {
|
||||
displayProviderIds,
|
||||
providerStateMap,
|
||||
shouldRender:
|
||||
isElectronMode() &&
|
||||
multimodelEnabled &&
|
||||
renderCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||
renderCliStatus.installed &&
|
||||
displayProviderIds.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const ProviderActivityStatusStrip = ({
|
||||
cliStatus,
|
||||
sourceCliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
multimodelEnabled,
|
||||
codexSnapshotPending = false,
|
||||
providerIds,
|
||||
className = '',
|
||||
label = 'Provider Activity',
|
||||
}: ProviderActivityStatusStripProps): React.JSX.Element | null => {
|
||||
const { displayProviderIds, providerStateMap, shouldRender } = useProviderActivityDisplay({
|
||||
cliStatus,
|
||||
sourceCliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
multimodelEnabled,
|
||||
codexSnapshotPending,
|
||||
providerIds,
|
||||
});
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex min-w-0 flex-wrap items-center gap-2 ${className}`.trim()}>
|
||||
{label ? (
|
||||
<span
|
||||
className="shrink-0 text-[11px] font-medium uppercase tracking-[0.08em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{displayProviderIds.map((providerId) => {
|
||||
const providerState = providerStateMap.get(providerId);
|
||||
if (!providerState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tone = providerState.loading
|
||||
? 'loading'
|
||||
: providerState.error
|
||||
? 'error'
|
||||
: 'checked';
|
||||
const styles = getActivityToneStyles(tone);
|
||||
const statusText =
|
||||
tone === 'loading'
|
||||
? 'Checking...'
|
||||
: tone === 'error'
|
||||
? formatProviderStatusText(providerState.provider)
|
||||
: 'Checked';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={providerId}
|
||||
data-testid={`provider-activity-status-${providerId}`}
|
||||
className="flex min-w-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: styles.borderColor,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
color: styles.textColor,
|
||||
}}
|
||||
>
|
||||
{tone === 'loading' ? (
|
||||
<Loader2
|
||||
className="size-3 shrink-0 animate-spin"
|
||||
style={{ color: styles.statusColor }}
|
||||
/>
|
||||
) : tone === 'error' ? (
|
||||
<AlertTriangle className="size-3 shrink-0" style={{ color: styles.statusColor }} />
|
||||
) : (
|
||||
<CheckCircle2 className="size-3 shrink-0" style={{ color: styles.statusColor }} />
|
||||
)}
|
||||
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0 font-medium" style={{ color: styles.textColor }}>
|
||||
{providerState.provider.displayName}
|
||||
</span>
|
||||
<span
|
||||
className="max-w-[280px] truncate"
|
||||
style={{ color: styles.statusColor }}
|
||||
title={statusText}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -7,9 +7,11 @@
|
|||
* Only rendered in Electron mode.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
|
||||
CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
|
||||
mergeCodexProviderStatusWithSnapshot,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
|
|
@ -31,16 +33,14 @@ import {
|
|||
isConnectionManagedRuntimeProvider,
|
||||
isOpenCodeCatalogHydrating,
|
||||
shouldShowProviderConnectAction,
|
||||
shouldShowProviderStatusSkeleton,
|
||||
} from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||
import {
|
||||
getProviderTerminalCommand,
|
||||
getProviderTerminalLogoutCommand,
|
||||
} from '@renderer/components/runtime/providerTerminalCommands';
|
||||
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||
import {
|
||||
loadDashboardCliStatusBannerCollapsed,
|
||||
|
|
@ -55,6 +55,7 @@ import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
|||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||
import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog';
|
||||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
|
|
@ -112,6 +113,22 @@ const ATLAS_CLOUD_CODING_PLAN_URL = 'https://www.atlascloud.ai/console/coding-pl
|
|||
const ATLAS_CLOUD_DESCRIPTION =
|
||||
"Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access.";
|
||||
|
||||
const ProviderRuntimeSettingsDialog = lazy(() =>
|
||||
import('@renderer/components/runtime/ProviderRuntimeSettingsDialog').then((module) => ({
|
||||
default: module.ProviderRuntimeSettingsDialog,
|
||||
}))
|
||||
);
|
||||
const TerminalLogPanel = lazy(() =>
|
||||
import('@renderer/components/terminal/TerminalLogPanel').then((module) => ({
|
||||
default: module.TerminalLogPanel,
|
||||
}))
|
||||
);
|
||||
const TerminalModal = lazy(() =>
|
||||
import('@renderer/components/terminal/TerminalModal').then((module) => ({
|
||||
default: module.TerminalModal,
|
||||
}))
|
||||
);
|
||||
|
||||
const DashboardRateLimitChips = ({
|
||||
providerId,
|
||||
items,
|
||||
|
|
@ -430,16 +447,6 @@ const ProviderDetailSkeleton = (): React.JSX.Element => {
|
|||
);
|
||||
};
|
||||
|
||||
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
(!provider.authenticated &&
|
||||
provider.statusMessage === 'Checking...' &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null)
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexSnapshotPending(
|
||||
provider: CliProviderStatus,
|
||||
codexSnapshotPending: boolean
|
||||
|
|
@ -489,6 +496,14 @@ function formatRuntimeLabel(
|
|||
: runtimeLabel;
|
||||
}
|
||||
|
||||
function isPendingMultimodelProviderStatus(provider: CliProviderStatus): boolean {
|
||||
return (
|
||||
!provider.authenticated &&
|
||||
(provider.statusMessage === 'Checking...' ||
|
||||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE)
|
||||
);
|
||||
}
|
||||
|
||||
function formatRuntimeAuthSummary(
|
||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
|
||||
visibleProviders: readonly CliProviderStatus[]
|
||||
|
|
@ -498,11 +513,7 @@ function formatRuntimeAuthSummary(
|
|||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
visibleProviders.every(
|
||||
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
||||
)
|
||||
) {
|
||||
if (visibleProviders.every(isPendingMultimodelProviderStatus)) {
|
||||
return 'Checking providers...';
|
||||
}
|
||||
const denominator = visibleProviders.length;
|
||||
|
|
@ -529,9 +540,7 @@ function isCheckingMultimodelStatus(
|
|||
return (
|
||||
isMultimodelRuntimeStatus(cliStatus) &&
|
||||
visibleProviders.length > 0 &&
|
||||
visibleProviders.every(
|
||||
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
|
||||
)
|
||||
visibleProviders.every(isPendingMultimodelProviderStatus)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -955,7 +964,7 @@ const InstalledBanner = ({
|
|||
provider
|
||||
);
|
||||
const showSkeleton =
|
||||
isProviderCardLoading(provider, providerLoading) ||
|
||||
shouldShowProviderStatusSkeleton(provider, providerLoading) ||
|
||||
isCodexSnapshotPending(provider, codexSnapshotPending) ||
|
||||
maskNegativeBootstrapState;
|
||||
const anthropicRateLimitsLoading =
|
||||
|
|
@ -1358,6 +1367,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
|
||||
initialRefreshMaxDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
|
||||
});
|
||||
const visibleCliProviders = useMemo(
|
||||
() =>
|
||||
|
|
@ -1653,50 +1664,58 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
const installedAuxiliaryUi =
|
||||
renderCliStatus !== null ? (
|
||||
<>
|
||||
<ProviderRuntimeSettingsDialog
|
||||
open={manageDialogOpen}
|
||||
onOpenChange={handleManageDialogOpenChange}
|
||||
providers={visibleCliProviders}
|
||||
projectPath={selectedProjectPath}
|
||||
initialProviderId={
|
||||
visibleCliProviders.some((provider) => provider.providerId === manageProviderId)
|
||||
? manageProviderId
|
||||
: (visibleCliProviders[0]?.providerId ?? 'anthropic')
|
||||
}
|
||||
initialRuntimeProviderId={manageRuntimeProviderId}
|
||||
initialRuntimeProviderAction={manageRuntimeProviderId ? 'connect' : null}
|
||||
providerStatusLoading={cliProviderStatusLoading}
|
||||
disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
|
||||
codexRuntimeStatus={codexRuntimeStatus}
|
||||
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||
onInstallCodexRuntime={() => installCodexRuntime()}
|
||||
onSelectBackend={handleProviderBackendChange}
|
||||
onRefreshProvider={handleProviderRefresh}
|
||||
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
|
||||
/>
|
||||
{manageDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ProviderRuntimeSettingsDialog
|
||||
open={manageDialogOpen}
|
||||
onOpenChange={handleManageDialogOpenChange}
|
||||
providers={visibleCliProviders}
|
||||
projectPath={selectedProjectPath}
|
||||
initialProviderId={
|
||||
visibleCliProviders.some((provider) => provider.providerId === manageProviderId)
|
||||
? manageProviderId
|
||||
: (visibleCliProviders[0]?.providerId ?? 'anthropic')
|
||||
}
|
||||
initialRuntimeProviderId={manageRuntimeProviderId}
|
||||
initialRuntimeProviderAction={manageRuntimeProviderId ? 'connect' : null}
|
||||
providerStatusLoading={cliProviderStatusLoading}
|
||||
disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
|
||||
codexRuntimeStatus={codexRuntimeStatus}
|
||||
codexRuntimeStatusLoading={codexRuntimeStatusLoading}
|
||||
onInstallCodexRuntime={() => installCodexRuntime()}
|
||||
onSelectBackend={handleProviderBackendChange}
|
||||
onRefreshProvider={handleProviderRefresh}
|
||||
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{providerTerminal && renderCliStatus.binaryPath && (
|
||||
<TerminalModal
|
||||
title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} ${
|
||||
providerTerminal.action === 'login' ? 'Login' : 'Logout'
|
||||
}: ${getProviderLabel(providerTerminal.providerId)}`}
|
||||
command={renderCliStatus.binaryPath}
|
||||
args={providerTerminalCommand?.args}
|
||||
env={providerTerminalCommand?.env}
|
||||
onClose={() => {
|
||||
setProviderTerminal(null);
|
||||
recheckAuthState();
|
||||
}}
|
||||
onExit={() => {
|
||||
recheckAuthState();
|
||||
}}
|
||||
autoCloseOnSuccessMs={3000}
|
||||
successMessage={
|
||||
providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
|
||||
}
|
||||
failureMessage={
|
||||
providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
|
||||
}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<TerminalModal
|
||||
title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} ${
|
||||
providerTerminal.action === 'login' ? 'Login' : 'Logout'
|
||||
}: ${getProviderLabel(providerTerminal.providerId)}`}
|
||||
command={renderCliStatus.binaryPath}
|
||||
args={providerTerminalCommand?.args}
|
||||
env={providerTerminalCommand?.env}
|
||||
onClose={() => {
|
||||
setProviderTerminal(null);
|
||||
recheckAuthState();
|
||||
}}
|
||||
onExit={() => {
|
||||
recheckAuthState();
|
||||
}}
|
||||
autoCloseOnSuccessMs={3000}
|
||||
successMessage={
|
||||
providerTerminal.action === 'login'
|
||||
? 'Authentication updated'
|
||||
: 'Provider logged out'
|
||||
}
|
||||
failureMessage={
|
||||
providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
|
@ -1875,7 +1894,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
Installing {runtimeDisplayName}...
|
||||
</span>
|
||||
</div>
|
||||
<TerminalLogPanel chunks={installerRawChunks} />
|
||||
<Suspense fallback={null}>
|
||||
<TerminalLogPanel chunks={installerRawChunks} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2248,45 +2269,47 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
</div>
|
||||
{installedAuxiliaryUi}
|
||||
{showLoginTerminal && renderCliStatus.binaryPath && (
|
||||
<TerminalModal
|
||||
title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} Login`}
|
||||
command={renderCliStatus.binaryPath}
|
||||
args={['auth', 'login']}
|
||||
onClose={() => {
|
||||
setShowLoginTerminal(false);
|
||||
setIsVerifyingAuth(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await invalidateCliStatus();
|
||||
if (multimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
<Suspense fallback={null}>
|
||||
<TerminalModal
|
||||
title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} Login`}
|
||||
command={renderCliStatus.binaryPath}
|
||||
args={['auth', 'login']}
|
||||
onClose={() => {
|
||||
setShowLoginTerminal(false);
|
||||
setIsVerifyingAuth(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await invalidateCliStatus();
|
||||
if (multimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingAuth(false);
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingAuth(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onExit={() => {
|
||||
setIsVerifyingAuth(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await invalidateCliStatus();
|
||||
if (multimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
})();
|
||||
}}
|
||||
onExit={() => {
|
||||
setIsVerifyingAuth(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await invalidateCliStatus();
|
||||
if (multimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingAuth(false);
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingAuth(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
autoCloseOnSuccessMs={4000}
|
||||
successMessage="Login complete"
|
||||
failureMessage="Login failed"
|
||||
/>
|
||||
})();
|
||||
}}
|
||||
autoCloseOnSuccessMs={4000}
|
||||
successMessage="Login complete"
|
||||
failureMessage="Login failed"
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -258,12 +258,21 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
}, [fetchPluginCatalog, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const cliStatusMatchesCurrentMode =
|
||||
cliStatus &&
|
||||
(multimodelEnabled
|
||||
? cliStatus.flavor === 'agent_teams_orchestrator'
|
||||
: cliStatus.flavor !== 'agent_teams_orchestrator');
|
||||
if (cliStatusLoading || cliStatusMatchesCurrentMode) {
|
||||
return;
|
||||
}
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
providerStatusMode: 'defer',
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
|
||||
}, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
// Fetch MCP installed state on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -512,6 +521,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
effectiveCliStatus,
|
||||
effectiveCliStatusLoading,
|
||||
openDashboard,
|
||||
runtimeDisplayName,
|
||||
]);
|
||||
|
||||
// Browser mode guard
|
||||
|
|
@ -586,7 +596,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
{tabState.activeSubTab === 'mcp-servers' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={mcpMutationDisableReason ? 0 : -1}>
|
||||
<span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention -- Component mocks mirror PascalCase exports. */
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
globalTaskDetail: null as null | { teamName: string; taskId: string },
|
||||
dialogModuleLoads: 0,
|
||||
dialogRenders: 0,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: <T,>(selector: (state: typeof mockState) => T): T => selector(mockState),
|
||||
}));
|
||||
|
||||
vi.mock('../team/dialogs/GlobalTaskDetailDialog', () => {
|
||||
mockState.dialogModuleLoads += 1;
|
||||
return {
|
||||
GlobalTaskDetailDialog: () => {
|
||||
mockState.dialogRenders += 1;
|
||||
return React.createElement('div', { 'data-testid': 'global-task-dialog' }, 'Task dialog');
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */
|
||||
|
||||
import { GlobalTaskDetailDialogSlot } from './GlobalTaskDetailDialogSlot';
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
const flushReact = async (): Promise<void> => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
};
|
||||
|
||||
const createHarness = (): { host: HTMLDivElement; root: Root } => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
roots.push(root);
|
||||
return { host, root };
|
||||
};
|
||||
|
||||
const renderSlot = async (root: Root): Promise<void> => {
|
||||
await act(async () => {
|
||||
root.render(<GlobalTaskDetailDialogSlot />);
|
||||
await flushReact();
|
||||
});
|
||||
};
|
||||
|
||||
const waitForDialog = async (host: HTMLElement): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if (host.querySelector('[data-testid="global-task-dialog"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await flushReact();
|
||||
});
|
||||
}
|
||||
|
||||
expect(host.querySelector('[data-testid="global-task-dialog"]')).not.toBeNull();
|
||||
};
|
||||
|
||||
describe('GlobalTaskDetailDialogSlot', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
mockState.globalTaskDetail = null;
|
||||
mockState.dialogModuleLoads = 0;
|
||||
mockState.dialogRenders = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
for (const root of roots.splice(0)) {
|
||||
root.unmount();
|
||||
}
|
||||
await flushReact();
|
||||
});
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not import the heavy task dialog until a global task is opened', async () => {
|
||||
const { host, root } = createHarness();
|
||||
|
||||
await renderSlot(root);
|
||||
|
||||
expect(host.querySelector('[data-testid="global-task-dialog"]')).toBeNull();
|
||||
expect(mockState.dialogModuleLoads).toBe(0);
|
||||
expect(mockState.dialogRenders).toBe(0);
|
||||
|
||||
mockState.globalTaskDetail = { teamName: 'team-a', taskId: 'task-1' };
|
||||
await renderSlot(root);
|
||||
await waitForDialog(host);
|
||||
|
||||
expect(mockState.dialogModuleLoads).toBe(1);
|
||||
expect(mockState.dialogRenders).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
|
||||
const GlobalTaskDetailDialog = lazy(() =>
|
||||
import('../team/dialogs/GlobalTaskDetailDialog').then((module) => ({
|
||||
default: module.GlobalTaskDetailDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
export const GlobalTaskDetailDialogSlot = (): React.JSX.Element | null => {
|
||||
const isOpen = useStore((state) => state.globalTaskDetail !== null);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<GlobalTaskDetailDialog />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
136
src/renderer/components/layout/PaneContent.test.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention -- Component mocks mirror PascalCase exports. */
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Pane } from '@renderer/types/panes';
|
||||
import type { Tab } from '@renderer/types/tabs';
|
||||
|
||||
vi.mock('../dashboard/DashboardView', () => ({
|
||||
DashboardView: () => React.createElement('div', { 'data-view': 'dashboard' }, 'Dashboard view'),
|
||||
}));
|
||||
|
||||
vi.mock('../extensions/ExtensionStoreView', () => ({
|
||||
ExtensionStoreView: () =>
|
||||
React.createElement('div', { 'data-view': 'extensions' }, 'Extension store view'),
|
||||
}));
|
||||
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */
|
||||
|
||||
import { PaneContent } from './PaneContent';
|
||||
|
||||
const flushReact = async (): Promise<void> => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
};
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
const dashboardTab: Tab = {
|
||||
id: 'tab-dashboard',
|
||||
type: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const extensionTab: Tab = {
|
||||
id: 'tab-extensions',
|
||||
type: 'extensions',
|
||||
label: 'Extensions',
|
||||
createdAt: 2,
|
||||
};
|
||||
|
||||
const createPane = (tabs: Tab[], activeTabId: string | null): Pane => ({
|
||||
id: 'pane-main',
|
||||
tabs,
|
||||
activeTabId,
|
||||
selectedTabIds: [],
|
||||
widthFraction: 1,
|
||||
});
|
||||
|
||||
const createHarness = (): { host: HTMLDivElement; root: Root } => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
roots.push(root);
|
||||
return { host, root };
|
||||
};
|
||||
|
||||
const renderPane = async (root: Root, pane: Pane): Promise<void> => {
|
||||
await act(async () => {
|
||||
root.render(<PaneContent pane={pane} isPaneFocused />);
|
||||
await flushReact();
|
||||
});
|
||||
};
|
||||
|
||||
const waitForText = async (host: HTMLElement, text: string): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if (host.textContent?.includes(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await flushReact();
|
||||
});
|
||||
}
|
||||
|
||||
expect(host.textContent).toContain(text);
|
||||
};
|
||||
|
||||
describe('PaneContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
for (const root of roots.splice(0)) {
|
||||
root.unmount();
|
||||
}
|
||||
await flushReact();
|
||||
});
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders the default dashboard without suspending when no tabs are open', async () => {
|
||||
const { host, root } = createHarness();
|
||||
|
||||
await renderPane(root, createPane([], null));
|
||||
|
||||
expect(host.textContent).toContain('Dashboard view');
|
||||
expect(host.querySelector('[role="status"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mount inactive lazy tab content during initial pane render', async () => {
|
||||
const { host, root } = createHarness();
|
||||
|
||||
await renderPane(root, createPane([dashboardTab, extensionTab], dashboardTab.id));
|
||||
|
||||
expect(host.textContent).toContain('Dashboard view');
|
||||
expect(host.textContent).not.toContain('Extension store view');
|
||||
expect(host.querySelector('[role="status"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('loads a lazy tab on first activation and keeps it mounted after switching away', async () => {
|
||||
const { host, root } = createHarness();
|
||||
|
||||
await renderPane(root, createPane([dashboardTab, extensionTab], dashboardTab.id));
|
||||
expect(host.textContent).not.toContain('Extension store view');
|
||||
|
||||
await renderPane(root, createPane([dashboardTab, extensionTab], extensionTab.id));
|
||||
await waitForText(host, 'Extension store view');
|
||||
|
||||
const extensionView = host.querySelector<HTMLElement>('[data-view="extensions"]');
|
||||
expect(extensionView).not.toBeNull();
|
||||
|
||||
await renderPane(root, createPane([dashboardTab, extensionTab], dashboardTab.id));
|
||||
|
||||
expect(host.querySelector('[data-view="extensions"]')).toBe(extensionView);
|
||||
expect(extensionView?.closest<HTMLElement>('.absolute')?.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
|
@ -3,27 +3,135 @@
|
|||
* Uses CSS display-toggle to keep all tabs mounted (preserving state).
|
||||
*/
|
||||
|
||||
import { TeamGraphTab } from '@features/agent-graph/renderer';
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import { TabUIProvider } from '@renderer/contexts/TabUIContext';
|
||||
|
||||
import { DashboardView } from '../dashboard/DashboardView';
|
||||
import { ExtensionStoreView } from '../extensions/ExtensionStoreView';
|
||||
import { NotificationsView } from '../notifications/NotificationsView';
|
||||
import { SessionReportTab } from '../report/SessionReportTab';
|
||||
import { SchedulesView } from '../schedules/SchedulesView';
|
||||
import { SettingsView } from '../settings/SettingsView';
|
||||
import { TeamDetailView } from '../team/TeamDetailView';
|
||||
import { TeamListView } from '../team/TeamListView';
|
||||
|
||||
import { SessionTabContent } from './SessionTabContent';
|
||||
|
||||
import type { Pane } from '@renderer/types/panes';
|
||||
import type { Tab } from '@renderer/types/tabs';
|
||||
|
||||
const ExtensionStoreView = lazy(() =>
|
||||
import('../extensions/ExtensionStoreView').then((module) => ({
|
||||
default: module.ExtensionStoreView,
|
||||
}))
|
||||
);
|
||||
const NotificationsView = lazy(() =>
|
||||
import('../notifications/NotificationsView').then((module) => ({
|
||||
default: module.NotificationsView,
|
||||
}))
|
||||
);
|
||||
const SessionReportTab = lazy(() =>
|
||||
import('../report/SessionReportTab').then((module) => ({
|
||||
default: module.SessionReportTab,
|
||||
}))
|
||||
);
|
||||
const SchedulesView = lazy(() =>
|
||||
import('../schedules/SchedulesView').then((module) => ({
|
||||
default: module.SchedulesView,
|
||||
}))
|
||||
);
|
||||
const SettingsView = lazy(() =>
|
||||
import('../settings/SettingsView').then((module) => ({
|
||||
default: module.SettingsView,
|
||||
}))
|
||||
);
|
||||
const TeamDetailView = lazy(() =>
|
||||
import('../team/TeamDetailView').then((module) => ({
|
||||
default: module.TeamDetailView,
|
||||
}))
|
||||
);
|
||||
const TeamListView = lazy(() =>
|
||||
import('../team/TeamListView').then((module) => ({
|
||||
default: module.TeamListView,
|
||||
}))
|
||||
);
|
||||
const SessionTabContent = lazy(() =>
|
||||
import('./SessionTabContent').then((module) => ({
|
||||
default: module.SessionTabContent,
|
||||
}))
|
||||
);
|
||||
const TeamGraphTab = lazy(() =>
|
||||
import('@features/agent-graph/renderer').then((module) => ({
|
||||
default: module.TeamGraphTab,
|
||||
}))
|
||||
);
|
||||
|
||||
interface PaneContentProps {
|
||||
pane: Pane;
|
||||
isPaneFocused: boolean;
|
||||
}
|
||||
|
||||
interface PaneTabSlotProps {
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
isPaneFocused: boolean;
|
||||
}
|
||||
|
||||
const PaneLazyFallback = (): React.JSX.Element => (
|
||||
<div className="flex flex-1 items-center justify-center bg-surface">
|
||||
<div
|
||||
className="size-5 animate-spin rounded-full border border-border border-t-text-muted"
|
||||
aria-label="Loading tab"
|
||||
role="status"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PaneTabSlot = ({ tab, isActive, isPaneFocused }: PaneTabSlotProps): React.JSX.Element => {
|
||||
const [hasActivated, setHasActivated] = useState(isActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setHasActivated(true);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex" style={{ display: isActive ? 'flex' : 'none' }}>
|
||||
{hasActivated && (
|
||||
<Suspense fallback={<PaneLazyFallback />}>
|
||||
{tab.type === 'dashboard' && <DashboardView />}
|
||||
{tab.type === 'notifications' && <NotificationsView />}
|
||||
{tab.type === 'settings' && <SettingsView />}
|
||||
{tab.type === 'teams' && <TeamListView />}
|
||||
{tab.type === 'team' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<TeamDetailView
|
||||
teamName={tab.teamName ?? ''}
|
||||
isActive={isActive}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'session' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<SessionTabContent tab={tab} isActive={isActive} />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'report' && <SessionReportTab tab={tab} />}
|
||||
{tab.type === 'extensions' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<ExtensionStoreView />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'schedules' && <SchedulesView />}
|
||||
{tab.type === 'graph' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<TeamGraphTab
|
||||
teamName={tab.teamName ?? ''}
|
||||
isActive={isActive}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</TabUIProvider>
|
||||
)}
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JSX.Element => {
|
||||
const activeTabId = pane.activeTabId;
|
||||
|
||||
|
|
@ -41,46 +149,7 @@ export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JS
|
|||
{pane.tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="absolute inset-0 flex"
|
||||
style={{ display: isActive ? 'flex' : 'none' }}
|
||||
>
|
||||
{tab.type === 'dashboard' && <DashboardView />}
|
||||
{tab.type === 'notifications' && <NotificationsView />}
|
||||
{tab.type === 'settings' && <SettingsView />}
|
||||
{tab.type === 'teams' && <TeamListView />}
|
||||
{tab.type === 'team' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<TeamDetailView
|
||||
teamName={tab.teamName ?? ''}
|
||||
isActive={isActive}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'session' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<SessionTabContent tab={tab} isActive={isActive} />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'report' && <SessionReportTab tab={tab} />}
|
||||
{tab.type === 'extensions' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<ExtensionStoreView />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'schedules' && <SchedulesView />}
|
||||
{tab.type === 'graph' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<TeamGraphTab
|
||||
teamName={tab.teamName ?? ''}
|
||||
isActive={isActive}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</TabUIProvider>
|
||||
)}
|
||||
</div>
|
||||
<PaneTabSlot key={tab.id} tab={tab} isActive={isActive} isPaneFocused={isPaneFocused} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
120
src/renderer/components/layout/Sidebar.test.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention -- Component mocks mirror PascalCase exports. */
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const storeMock = vi.hoisted(() => ({
|
||||
state: {
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: vi.fn(),
|
||||
},
|
||||
sessionsModuleLoads: 0,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: <T,>(selector: (state: typeof storeMock.state) => T): T => selector(storeMock.state),
|
||||
}));
|
||||
|
||||
vi.mock('../sidebar/GlobalTaskList', () => ({
|
||||
GlobalTaskList: () => React.createElement('div', { 'data-testid': 'tasks-panel' }, 'Tasks panel'),
|
||||
}));
|
||||
|
||||
vi.mock('../sidebar/DateGroupedSessions', () => {
|
||||
storeMock.sessionsModuleLoads += 1;
|
||||
return {
|
||||
DateGroupedSessions: () =>
|
||||
React.createElement('div', { 'data-testid': 'sessions-panel' }, 'Sessions panel'),
|
||||
};
|
||||
});
|
||||
|
||||
/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */
|
||||
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
const flushReact = async (): Promise<void> => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
};
|
||||
|
||||
const createHarness = (): { host: HTMLDivElement; root: Root } => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
roots.push(root);
|
||||
return { host, root };
|
||||
};
|
||||
|
||||
const renderSidebar = async (root: Root): Promise<void> => {
|
||||
await act(async () => {
|
||||
root.render(<Sidebar />);
|
||||
await flushReact();
|
||||
});
|
||||
};
|
||||
|
||||
function findButtonByText(host: HTMLElement, text: string): HTMLButtonElement {
|
||||
const button = Array.from(host.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === text
|
||||
);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Button not found: ${text}`);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeMock.state.sidebarCollapsed = false;
|
||||
storeMock.state.toggleSidebar.mockClear();
|
||||
storeMock.sessionsModuleLoads = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
for (const root of roots.splice(0)) {
|
||||
root.unmount();
|
||||
}
|
||||
await flushReact();
|
||||
});
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not mount the sessions panel before the sessions tab is opened', async () => {
|
||||
const { host, root } = createHarness();
|
||||
|
||||
await renderSidebar(root);
|
||||
|
||||
expect(host.querySelector('[data-testid="tasks-panel"]')).not.toBeNull();
|
||||
expect(host.querySelector('[data-testid="sessions-panel"]')).toBeNull();
|
||||
expect(storeMock.sessionsModuleLoads).toBe(0);
|
||||
});
|
||||
|
||||
it('mounts the sessions panel on first activation and keeps it mounted when hidden', async () => {
|
||||
const { host, root } = createHarness();
|
||||
await renderSidebar(root);
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(host, 'Sessions').click();
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
const sessionsPanel = host.querySelector('[data-testid="sessions-panel"]');
|
||||
expect(sessionsPanel).not.toBeNull();
|
||||
expect(sessionsPanel?.closest<HTMLElement>('[role="tabpanel"]')?.hidden).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(host, 'Tasks').click();
|
||||
await flushReact();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="sessions-panel"]')).toBe(sessionsPanel);
|
||||
expect(sessionsPanel?.closest<HTMLElement>('[role="tabpanel"]')?.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,14 +8,13 @@
|
|||
* - Collapsible: Cmd+B to toggle (Notion-style)
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatShortcut } from '@renderer/utils/stringUtils';
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { DateGroupedSessions } from '../sidebar/DateGroupedSessions';
|
||||
import { GlobalTaskList } from '../sidebar/GlobalTaskList';
|
||||
import { defaultTaskFiltersState } from '../sidebar/taskFiltersState';
|
||||
|
||||
|
|
@ -23,6 +22,12 @@ import type { TaskFiltersState } from '../sidebar/taskFiltersState';
|
|||
|
||||
type SidebarTab = 'tasks' | 'sessions';
|
||||
|
||||
const DateGroupedSessions = lazy(() =>
|
||||
import('../sidebar/DateGroupedSessions').then((module) => ({
|
||||
default: module.DateGroupedSessions,
|
||||
}))
|
||||
);
|
||||
|
||||
const MIN_WIDTH = 200;
|
||||
const MAX_WIDTH = 500;
|
||||
const DEFAULT_WIDTH = 280;
|
||||
|
|
@ -37,6 +42,7 @@ export const Sidebar = (): React.JSX.Element => {
|
|||
const [width, setWidth] = useState(DEFAULT_WIDTH);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [sidebarTab, setSidebarTab] = useState<SidebarTab>('tasks');
|
||||
const [hasOpenedSessionsTab, setHasOpenedSessionsTab] = useState(false);
|
||||
const [taskFilters, setTaskFilters] = useState<TaskFiltersState>(defaultTaskFiltersState);
|
||||
const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false);
|
||||
const [isCollapseHovered, setIsCollapseHovered] = useState(false);
|
||||
|
|
@ -77,6 +83,12 @@ export const Sidebar = (): React.JSX.Element => {
|
|||
};
|
||||
}, [isResizing, handleMouseMove, handleMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarTab === 'sessions') {
|
||||
setHasOpenedSessionsTab(true);
|
||||
}
|
||||
}, [sidebarTab]);
|
||||
|
||||
const handleResizeStart = (e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
|
|
@ -195,7 +207,11 @@ export const Sidebar = (): React.JSX.Element => {
|
|||
hidden={sidebarTab !== 'sessions'}
|
||||
className="min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<DateGroupedSessions />
|
||||
{hasOpenedSessionsTab && (
|
||||
<Suspense fallback={null}>
|
||||
<DateGroupedSessions />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ import { UpdateBanner } from '../common/UpdateBanner';
|
|||
import { UpdateDialog } from '../common/UpdateDialog';
|
||||
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
|
||||
import { CommandPalette } from '../search/CommandPalette';
|
||||
import { GlobalTaskDetailDialog } from '../team/dialogs/GlobalTaskDetailDialog';
|
||||
|
||||
import { CustomTitleBar } from './CustomTitleBar';
|
||||
import { GlobalTaskDetailDialogSlot } from './GlobalTaskDetailDialogSlot';
|
||||
import { PaneContainer } from './PaneContainer';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { DragOverlayTab } from './SortableTab';
|
||||
|
|
@ -186,7 +186,7 @@ export const TabbedLayout = (): React.JSX.Element => {
|
|||
{activeTab ? <DragOverlayTab tab={activeTab} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<GlobalTaskDetailDialog />
|
||||
<GlobalTaskDetailDialogSlot />
|
||||
<UpdateDialog />
|
||||
<WorkspaceIndicator />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
|
||||
import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types';
|
||||
|
||||
const CODEX_NATIVE_LABEL = 'Codex native';
|
||||
|
|
@ -130,6 +132,48 @@ export function isOpenCodeCatalogHydrating(
|
|||
);
|
||||
}
|
||||
|
||||
function hasKnownProviderStatus(
|
||||
provider: Pick<
|
||||
CliProviderStatus,
|
||||
| 'authenticated'
|
||||
| 'supported'
|
||||
| 'statusMessage'
|
||||
| 'models'
|
||||
| 'backend'
|
||||
| 'availableBackends'
|
||||
| 'connection'
|
||||
| 'modelCatalog'
|
||||
>
|
||||
): boolean {
|
||||
const statusMessage = provider.statusMessage?.trim() ?? '';
|
||||
return (
|
||||
provider.authenticated ||
|
||||
provider.supported ||
|
||||
provider.models.length > 0 ||
|
||||
provider.backend != null ||
|
||||
(provider.availableBackends?.length ?? 0) > 0 ||
|
||||
provider.connection != null ||
|
||||
provider.modelCatalog != null ||
|
||||
(statusMessage.length > 0 &&
|
||||
statusMessage !== 'Checking...' &&
|
||||
statusMessage !== CLI_PROVIDER_STATUS_DEFERRED_MESSAGE)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowProviderStatusSkeleton(
|
||||
provider: CliProviderStatus,
|
||||
providerLoading: boolean
|
||||
): boolean {
|
||||
const isPlaceholder =
|
||||
!provider.authenticated &&
|
||||
(provider.statusMessage === 'Checking...' ||
|
||||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null;
|
||||
|
||||
return isPlaceholder || (providerLoading && !hasKnownProviderStatus(provider));
|
||||
}
|
||||
|
||||
export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean {
|
||||
return provider.providerId === 'codex';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
isConnectionManagedRuntimeProvider,
|
||||
isOpenCodeCatalogHydrating,
|
||||
shouldShowProviderConnectAction,
|
||||
shouldShowProviderStatusSkeleton,
|
||||
} from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||
|
|
@ -83,16 +84,6 @@ const ProviderDetailSkeleton = (): React.JSX.Element => {
|
|||
);
|
||||
};
|
||||
|
||||
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||
return (
|
||||
providerLoading ||
|
||||
(!provider.authenticated &&
|
||||
provider.statusMessage === 'Checking...' &&
|
||||
provider.models.length === 0 &&
|
||||
provider.backend == null)
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexSnapshotPending(
|
||||
provider: CliProviderStatus,
|
||||
codexSnapshotPending: boolean
|
||||
|
|
@ -474,7 +465,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
const providerLoading =
|
||||
cliProviderStatusLoading[provider.providerId] === true;
|
||||
const showSkeleton =
|
||||
isProviderCardLoading(provider, providerLoading) ||
|
||||
shouldShowProviderStatusSkeleton(provider, providerLoading) ||
|
||||
isCodexSnapshotPending(provider, codexSnapshotPending);
|
||||
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
|
||||
? getProviderCurrentRuntimeSummary(provider)
|
||||
|
|
|
|||
|
|
@ -274,24 +274,26 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
|
|||
// Loading guards in the store actions prevent duplicate IPC calls
|
||||
// when the centralized init chain has already started a fetch.
|
||||
const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading);
|
||||
const repositoryGroupsInitialized = useStore((s) => s.repositoryGroupsInitialized);
|
||||
const repositoryGroupsError = useStore((s) => s.repositoryGroupsError);
|
||||
const projectsLoading = useStore((s) => s.projectsLoading);
|
||||
const projectsInitialized = useStore((s) => s.projectsInitialized);
|
||||
const projectsError = useStore((s) => s.projectsError);
|
||||
useEffect(() => {
|
||||
if (
|
||||
viewMode === 'grouped' &&
|
||||
repositoryGroups.length === 0 &&
|
||||
!repositoryGroupsInitialized &&
|
||||
!repositoryGroupsLoading &&
|
||||
!repositoryGroupsError
|
||||
) {
|
||||
void fetchRepositoryGroups();
|
||||
} else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading && !projectsError) {
|
||||
} else if (viewMode === 'flat' && !projectsInitialized && !projectsLoading && !projectsError) {
|
||||
void fetchProjects();
|
||||
}
|
||||
}, [
|
||||
viewMode,
|
||||
repositoryGroups.length,
|
||||
projects.length,
|
||||
repositoryGroupsInitialized,
|
||||
projectsInitialized,
|
||||
repositoryGroupsLoading,
|
||||
repositoryGroupsError,
|
||||
projectsLoading,
|
||||
|
|
|
|||
|
|
@ -201,10 +201,18 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
globalTasksLoading,
|
||||
globalTasksInitialized,
|
||||
fetchAllTasks,
|
||||
fetchProjects,
|
||||
fetchRepositoryGroups,
|
||||
softDeleteTask,
|
||||
projects,
|
||||
projectsLoading,
|
||||
projectsInitialized,
|
||||
projectsError,
|
||||
viewMode,
|
||||
repositoryGroups,
|
||||
repositoryGroupsLoading,
|
||||
repositoryGroupsInitialized,
|
||||
repositoryGroupsError,
|
||||
teams,
|
||||
provisioningRuns,
|
||||
currentProvisioningRunIdByTeam,
|
||||
|
|
@ -215,10 +223,18 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
globalTasksLoading: s.globalTasksLoading,
|
||||
globalTasksInitialized: s.globalTasksInitialized,
|
||||
fetchAllTasks: s.fetchAllTasks,
|
||||
fetchProjects: s.fetchProjects,
|
||||
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
||||
softDeleteTask: s.softDeleteTask,
|
||||
projects: s.projects,
|
||||
projectsLoading: s.projectsLoading,
|
||||
projectsInitialized: s.projectsInitialized,
|
||||
projectsError: s.projectsError,
|
||||
viewMode: s.viewMode,
|
||||
repositoryGroups: s.repositoryGroups,
|
||||
repositoryGroupsLoading: s.repositoryGroupsLoading,
|
||||
repositoryGroupsInitialized: s.repositoryGroupsInitialized,
|
||||
repositoryGroupsError: s.repositoryGroupsError,
|
||||
teams: s.teams,
|
||||
provisioningRuns: s.provisioningRuns,
|
||||
currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam,
|
||||
|
|
@ -442,6 +458,29 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
}
|
||||
}, [fetchAllTasks, globalTasksLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
viewMode === 'grouped' &&
|
||||
!repositoryGroupsInitialized &&
|
||||
!repositoryGroupsLoading &&
|
||||
!repositoryGroupsError
|
||||
) {
|
||||
void fetchRepositoryGroups();
|
||||
} else if (viewMode === 'flat' && !projectsInitialized && !projectsLoading && !projectsError) {
|
||||
void fetchProjects();
|
||||
}
|
||||
}, [
|
||||
fetchProjects,
|
||||
fetchRepositoryGroups,
|
||||
projectsError,
|
||||
projectsInitialized,
|
||||
projectsLoading,
|
||||
repositoryGroupsError,
|
||||
repositoryGroupsInitialized,
|
||||
repositoryGroupsLoading,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
// Build project combobox options from available projects/repos
|
||||
const projectFilterOptions = useMemo((): ComboboxOption[] => {
|
||||
const items =
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const TEAM_MEMBER_COUNTS = [4, 3, 5] as const;
|
|||
const TEAM_MEMBER_OFFSETS = [0, 4, 7] as const;
|
||||
const TEAM_LABELS = ['Marketing', 'Researchers', 'Coding'] as const;
|
||||
const MAX_DPR = 2;
|
||||
const SPLASH_SCENE_FRAME_INTERVAL_MS = 1000 / 30;
|
||||
const avatarCache = new Map<string, HTMLImageElement>();
|
||||
const avatarLoading = new Map<string, Promise<HTMLImageElement | null>>();
|
||||
|
||||
|
|
@ -112,6 +113,7 @@ export function startSplashScene(
|
|||
particles: [] as DepthParticle[],
|
||||
running: true,
|
||||
frameId: 0,
|
||||
lastRenderedAt: 0,
|
||||
startedAt: performance.now(),
|
||||
};
|
||||
|
||||
|
|
@ -139,9 +141,16 @@ export function startSplashScene(
|
|||
const render = (now: number): void => {
|
||||
if (!state.running) return;
|
||||
|
||||
resize();
|
||||
const time = (now - state.startedAt) / 1000;
|
||||
drawScene(ctx, state.width, state.height, time, state.particles, reducedMotion);
|
||||
if (
|
||||
reducedMotion ||
|
||||
state.lastRenderedAt === 0 ||
|
||||
now - state.lastRenderedAt >= SPLASH_SCENE_FRAME_INTERVAL_MS
|
||||
) {
|
||||
resize();
|
||||
const time = (now - state.startedAt) / 1000;
|
||||
drawScene(ctx, state.width, state.height, time, state.particles, reducedMotion);
|
||||
state.lastRenderedAt = now;
|
||||
}
|
||||
|
||||
if (!reducedMotion) {
|
||||
state.frameId = window.requestAnimationFrame(render);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
resolveCodexRuntimeSelection,
|
||||
} from '@features/codex-runtime-profile/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
buildMemberDraftSuggestions,
|
||||
|
|
@ -396,8 +397,12 @@ export const CreateTeamDialog = ({
|
|||
const anthropicProviderFastModeDefault = useStore(
|
||||
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
|
||||
);
|
||||
const { cliStatus, cliStatusLoading } = useStore(
|
||||
useShallow((s) => ({ cliStatus: s.cliStatus, cliStatusLoading: s.cliStatusLoading }))
|
||||
const { cliStatus, cliStatusLoading, cliProviderStatusLoading } = useStore(
|
||||
useShallow((s) => ({
|
||||
cliStatus: s.cliStatus,
|
||||
cliStatusLoading: s.cliStatusLoading,
|
||||
cliProviderStatusLoading: s.cliProviderStatusLoading,
|
||||
}))
|
||||
);
|
||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||
|
|
@ -419,6 +424,10 @@ export const CreateTeamDialog = ({
|
|||
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
|
||||
[loadingCliStatus, codexAccount.snapshot]
|
||||
);
|
||||
const codexSnapshotPending =
|
||||
codexAccount.loading &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
|
||||
!codexAccount.snapshot;
|
||||
|
||||
// ── Persisted draft state (survives tab navigation) ──────────────────
|
||||
const {
|
||||
|
|
@ -2457,6 +2466,18 @@ export const CreateTeamDialog = ({
|
|||
|
||||
<DialogFooter className="pt-4 sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{canCreate && launchTeam ? (
|
||||
<ProviderActivityStatusStrip
|
||||
cliStatus={effectiveCliStatus}
|
||||
sourceCliStatus={loadingCliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
providerIds={selectedMemberProviders}
|
||||
className="mb-2"
|
||||
/>
|
||||
) : null}
|
||||
{canCreate &&
|
||||
launchTeam &&
|
||||
(effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
resolveCodexRuntimeSelection,
|
||||
} from '@features/codex-runtime-profile/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
|
||||
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||
import {
|
||||
buildMemberDraftColorMap,
|
||||
|
|
@ -356,6 +357,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
);
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading);
|
||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||
const isLaunchMode = props.mode === 'launch' || props.mode === 'relaunch';
|
||||
|
|
@ -377,6 +379,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
|
||||
[loadingCliStatus, codexAccount.snapshot]
|
||||
);
|
||||
const codexSnapshotPending =
|
||||
codexAccount.loading &&
|
||||
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
|
||||
!codexAccount.snapshot;
|
||||
const isSchedule = props.mode === 'schedule';
|
||||
const schedule = isSchedule ? (props.schedule ?? null) : null;
|
||||
const isEditing = isSchedule && !!schedule;
|
||||
|
|
@ -3040,6 +3046,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
{/* Launch-only: CLI warm-up status */}
|
||||
{isLaunchMode ? (
|
||||
<div className="min-w-0">
|
||||
<ProviderActivityStatusStrip
|
||||
cliStatus={effectiveCliStatus}
|
||||
sourceCliStatus={loadingCliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
codexSnapshotPending={codexSnapshotPending}
|
||||
providerIds={selectedMemberProviders}
|
||||
className="mb-2"
|
||||
/>
|
||||
{effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ import { useStore } from '@renderer/store';
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
|
||||
import type { CliInstallationStatus, CliProviderId, OpenCodeRuntimeStatus } from '@shared/types';
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
CliInstallerProviderStatusMode,
|
||||
CliProviderId,
|
||||
OpenCodeRuntimeStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
export function useCliInstaller(): {
|
||||
cliStatus: CliInstallationStatus | null;
|
||||
|
|
@ -37,7 +42,10 @@ export function useCliInstaller(): {
|
|||
codexRuntimeStatus: CodexRuntimeStatus | null;
|
||||
codexRuntimeStatusLoading: boolean;
|
||||
codexRuntimeError: string | null;
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
bootstrapCliStatus: (options?: {
|
||||
multimodelEnabled?: boolean;
|
||||
providerStatusMode?: CliInstallerProviderStatusMode;
|
||||
}) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
fetchCliProviderStatus: (
|
||||
providerId: CliProviderId,
|
||||
|
|
|
|||
|
|
@ -561,6 +561,7 @@
|
|||
var TEAM_MEMBER_OFFSETS = [0, 4, 7];
|
||||
var TEAM_LABELS = ['Marketing', 'Researchers', 'Coding'];
|
||||
var MAX_DPR = 2;
|
||||
var SPLASH_SCENE_FRAME_INTERVAL_MS = 1000 / 30;
|
||||
var FALLBACK_AVATAR_URLS = [
|
||||
'./assets/participant-avatars/01.png',
|
||||
'./assets/participant-avatars/02.png',
|
||||
|
|
@ -637,6 +638,7 @@
|
|||
particles: [],
|
||||
running: true,
|
||||
frameId: 0,
|
||||
lastRenderedAt: 0,
|
||||
startedAt: performance.now(),
|
||||
};
|
||||
|
||||
|
|
@ -661,14 +663,21 @@
|
|||
|
||||
function render(now) {
|
||||
if (!state.running) return;
|
||||
resize();
|
||||
drawScene(
|
||||
ctx,
|
||||
state.width,
|
||||
state.height,
|
||||
reducedMotion ? 1.2 : (now - state.startedAt) / 1000,
|
||||
state.particles
|
||||
);
|
||||
if (
|
||||
reducedMotion ||
|
||||
state.lastRenderedAt === 0 ||
|
||||
now - state.lastRenderedAt >= SPLASH_SCENE_FRAME_INTERVAL_MS
|
||||
) {
|
||||
resize();
|
||||
drawScene(
|
||||
ctx,
|
||||
state.width,
|
||||
state.height,
|
||||
reducedMotion ? 1.2 : (now - state.startedAt) / 1000,
|
||||
state.particles
|
||||
);
|
||||
state.lastRenderedAt = now;
|
||||
}
|
||||
if (!reducedMotion) state.frameId = window.requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ import { App } from './App';
|
|||
import { initSentryRenderer } from './sentry';
|
||||
import { initializeNotificationListeners } from './store';
|
||||
|
||||
import type { SplashSceneHandle } from './components/splash/splashScene';
|
||||
import type { AppStartupStatus, AppStartupStep } from '@shared/types/api';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__claudeTeamsUiDidInit?: boolean;
|
||||
__claudeTeamsSplashStaticTimer?: number;
|
||||
__claudeTeamsSplashScene?: SplashSceneHandle;
|
||||
__claudeTeamsSplashEnhancedDisabled?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +173,11 @@ function stopStartupTicker(): void {
|
|||
startupTicker = undefined;
|
||||
}
|
||||
|
||||
function stopEnhancedSplashScene(): void {
|
||||
window.__claudeTeamsSplashEnhancedDisabled = true;
|
||||
window.__claudeTeamsSplashScene?.stop();
|
||||
}
|
||||
|
||||
function mountApp(): void {
|
||||
if (root) return;
|
||||
|
||||
|
|
@ -204,6 +212,11 @@ async function bootstrapRenderer(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
updateStartupSplash(nextStatus);
|
||||
const currentStep = getCurrentStartupStep(nextStatus);
|
||||
const stepElapsedMs = getStepElapsedMs(currentStep, nextStatus);
|
||||
if (!nextStatus.ready && (nextStatus.error || stepElapsedMs >= VERY_SLOW_STEP_MS)) {
|
||||
stopEnhancedSplashScene();
|
||||
}
|
||||
if (nextStatus.ready) {
|
||||
finished = true;
|
||||
cleanup();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { syncRendererTelemetry } from '@renderer/sentry';
|
|||
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { scheduleStartupIdleTask } from '@renderer/utils/startupIdleTask';
|
||||
import {
|
||||
buildTaskChangePresenceKey,
|
||||
buildTaskChangeRequestOptions,
|
||||
|
|
@ -21,6 +22,7 @@ import { create } from 'zustand';
|
|||
import { createChangeReviewSlice } from './slices/changeReviewSlice';
|
||||
import {
|
||||
createCliInstallerSlice,
|
||||
getIncompleteMultimodelProviderIds,
|
||||
getModelOnlyFallbackProviderIds,
|
||||
mergeCliStatusPreservingHydratedProviders,
|
||||
reconcileMultimodelProviderLoading,
|
||||
|
|
@ -95,6 +97,9 @@ const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
|
|||
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
||||
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
|
||||
const TASK_LOG_ACTIVITY_PULSE_MS = 3_500;
|
||||
const STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000;
|
||||
const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000;
|
||||
const STARTUP_PROVIDER_STATUS_MAX_DELAY_MS = 30_000;
|
||||
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
|
||||
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
||||
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
|
||||
|
|
@ -209,15 +214,16 @@ export function initializeNotificationListeners(): () => void {
|
|||
const cleanupFns: (() => void)[] = [];
|
||||
cleanupFns.push(installTeamRefreshFanoutDebugBridge());
|
||||
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let deferredProviderStatusCleanup: (() => void) | null = null;
|
||||
useStore.getState().subscribeProvisioningProgress();
|
||||
cleanupFns.push(() => {
|
||||
useStore.getState().unsubscribeProvisioningProgress();
|
||||
});
|
||||
// Initial data fetches. Config loads first (needed for theme), then the rest
|
||||
// run in parallel (no data dependencies between them). UV_THREADPOOL_SIZE=16
|
||||
// prevents thread pool saturation even with concurrent I/O on Windows.
|
||||
// Components also fire these from useEffect — loading guards in each action
|
||||
// prevent duplicate IPC calls (whichever caller starts first wins).
|
||||
// Initial data fetches. Config loads first (needed for theme), then the
|
||||
// immediately visible data follows. Repository grouping is owned by the
|
||||
// Sessions sidebar tab when it first mounts; scanning it here made startup
|
||||
// pay for a hidden panel.
|
||||
void (async () => {
|
||||
// Config: fast (in-memory read) — needed for theme before first paint.
|
||||
await useStore.getState().fetchConfig();
|
||||
|
|
@ -241,24 +247,47 @@ export function initializeNotificationListeners(): () => void {
|
|||
cliStatusTimer = setTimeout(() => {
|
||||
const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
|
||||
if (multimodelEnabled) {
|
||||
void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true });
|
||||
void (async () => {
|
||||
await useStore
|
||||
.getState()
|
||||
.bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' });
|
||||
if (deferredProviderStatusCleanup) {
|
||||
deferredProviderStatusCleanup();
|
||||
}
|
||||
deferredProviderStatusCleanup = scheduleStartupIdleTask(
|
||||
() => {
|
||||
const providerIds = getIncompleteMultimodelProviderIds(
|
||||
useStore.getState().cliStatus
|
||||
);
|
||||
for (const providerId of providerIds) {
|
||||
void useStore.getState().fetchCliProviderStatus(providerId, { silent: false });
|
||||
}
|
||||
deferredProviderStatusCleanup = null;
|
||||
},
|
||||
{
|
||||
minDelayMs: STARTUP_PROVIDER_STATUS_MIN_DELAY_MS,
|
||||
maxDelayMs: STARTUP_PROVIDER_STATUS_MAX_DELAY_MS,
|
||||
}
|
||||
);
|
||||
})();
|
||||
} else {
|
||||
void useStore.getState().fetchCliStatus();
|
||||
}
|
||||
cliStatusTimer = null;
|
||||
}, delayMs);
|
||||
}
|
||||
if (api.openCodeRuntime) {
|
||||
void useStore.getState().fetchOpenCodeRuntimeStatus();
|
||||
}
|
||||
if (api.codexRuntime) {
|
||||
void useStore.getState().fetchCodexRuntimeStatus();
|
||||
}
|
||||
runtimeStatusTimer = setTimeout(() => {
|
||||
if (api.openCodeRuntime) {
|
||||
void useStore.getState().fetchOpenCodeRuntimeStatus();
|
||||
}
|
||||
if (api.codexRuntime) {
|
||||
void useStore.getState().fetchCodexRuntimeStatus();
|
||||
}
|
||||
runtimeStatusTimer = null;
|
||||
}, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS);
|
||||
|
||||
// Remaining fetches have no data dependency on each other — run in parallel
|
||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||
// Remaining visible startup fetches have no data dependency on each other.
|
||||
await Promise.all([
|
||||
useStore.getState().fetchRepositoryGroups(),
|
||||
useStore.getState().fetchAllTasks(),
|
||||
useStore.getState().fetchTeams(),
|
||||
useStore.getState().fetchNotifications(),
|
||||
|
|
@ -267,6 +296,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
})();
|
||||
cleanupFns.push(() => {
|
||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
|
||||
if (deferredProviderStatusCleanup) deferredProviderStatusCleanup();
|
||||
});
|
||||
// TODO(task-change-presence): re-enable this only after the board uses a bounded
|
||||
// batch/priority presence pipeline. The old one-task-per-tick poll was accurate
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
|
||||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
|
|
@ -109,11 +110,25 @@ function isOpenCodeSummaryOnlyCatalogStatus(provider: CliProviderStatus | undefi
|
|||
return provider.runtimeCapabilities?.modelCatalog?.dynamic === true;
|
||||
}
|
||||
|
||||
function isDeferredMultimodelProviderStatus(provider: CliProviderStatus | undefined): boolean {
|
||||
return (
|
||||
provider?.supported === false &&
|
||||
provider.authenticated === false &&
|
||||
provider.authMethod === null &&
|
||||
provider.verificationState === 'unknown' &&
|
||||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE
|
||||
);
|
||||
}
|
||||
|
||||
function isHydratedMultimodelProviderStatus(provider: CliProviderStatus | undefined): boolean {
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDeferredMultimodelProviderStatus(provider)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isModelOnlyFallbackProviderStatus(provider)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -223,6 +238,17 @@ function mergeProviderCatalogCache(
|
|||
};
|
||||
}
|
||||
|
||||
function mergePreservedHydratedProviderStatus(
|
||||
incomingProvider: CliProviderStatus,
|
||||
currentProvider: CliProviderStatus
|
||||
): CliProviderStatus {
|
||||
if (isDeferredMultimodelProviderStatus(incomingProvider)) {
|
||||
return currentProvider;
|
||||
}
|
||||
|
||||
return mergeProviderCatalogCache(incomingProvider, currentProvider);
|
||||
}
|
||||
|
||||
export function getIncompleteMultimodelProviderIds(
|
||||
status: CliInstallationStatus | null
|
||||
): CliProviderId[] {
|
||||
|
|
@ -442,7 +468,7 @@ export function mergeCliStatusPreservingHydratedProviders(
|
|||
return incomingProvider;
|
||||
}
|
||||
if (shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) {
|
||||
return mergeProviderCatalogCache(incomingProvider, currentProvider);
|
||||
return mergePreservedHydratedProviderStatus(incomingProvider, currentProvider);
|
||||
}
|
||||
// Preserve the current reference when content is identical so the
|
||||
// providers array stays reference-stable across steady-state IPC polls.
|
||||
|
|
@ -659,7 +685,10 @@ export interface CliInstallerSlice {
|
|||
codexRuntimeError: string | null;
|
||||
|
||||
// Actions
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
bootstrapCliStatus: (options?: {
|
||||
multimodelEnabled?: boolean;
|
||||
providerStatusMode?: 'full' | 'defer';
|
||||
}) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
fetchCliProviderStatus: (
|
||||
providerId: CliProviderId,
|
||||
|
|
@ -714,24 +743,41 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
bootstrapCliStatus: async (options) => {
|
||||
if (!api.cliInstaller) return;
|
||||
const multimodelEnabled = options?.multimodelEnabled ?? true;
|
||||
const providerStatusMode = options?.providerStatusMode ?? 'full';
|
||||
const hydrateProviders = providerStatusMode !== 'defer';
|
||||
if (!multimodelEnabled) {
|
||||
return get().fetchCliStatus();
|
||||
}
|
||||
|
||||
const epoch = ++cliStatusEpoch;
|
||||
const currentStatus = get().cliStatus;
|
||||
const initialStatus =
|
||||
providerStatusMode === 'defer' && currentStatus?.flavor === 'agent_teams_orchestrator'
|
||||
? currentStatus
|
||||
: createLoadingMultimodelCliStatus();
|
||||
const shouldMarkIncompleteProvidersLoading = hydrateProviders || providerStatusMode === 'defer';
|
||||
const providerLoading = Object.fromEntries(
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, true])
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
|
||||
providerId,
|
||||
shouldMarkIncompleteProvidersLoading &&
|
||||
initialStatus.installed &&
|
||||
!isHydratedMultimodelProviderStatus(
|
||||
initialStatus.providers.find((provider) => provider.providerId === providerId)
|
||||
),
|
||||
])
|
||||
) as Partial<Record<CliProviderId, boolean>>;
|
||||
|
||||
set({
|
||||
cliStatus: createLoadingMultimodelCliStatus(),
|
||||
cliStatus: initialStatus,
|
||||
cliStatusLoading: true,
|
||||
cliProviderStatusLoading: providerLoading,
|
||||
cliStatusError: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const metadata = await api.cliInstaller.getStatus();
|
||||
const metadata = await api.cliInstaller.getStatus(
|
||||
providerStatusMode === 'defer' ? { providerStatusMode } : undefined
|
||||
);
|
||||
if (metadata.flavor !== 'agent_teams_orchestrator') {
|
||||
set((state) => {
|
||||
if (epoch !== cliStatusEpoch) {
|
||||
|
|
@ -753,17 +799,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
return;
|
||||
}
|
||||
|
||||
const nextProviderLoading = Object.fromEntries(
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
|
||||
providerId,
|
||||
!isHydratedMultimodelProviderStatus(
|
||||
metadata.providers.find((provider) => provider.providerId === providerId)
|
||||
),
|
||||
])
|
||||
) as Partial<Record<CliProviderId, boolean>>;
|
||||
const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter(
|
||||
(providerId) => nextProviderLoading[providerId] === true
|
||||
);
|
||||
let pendingProviderIds: CliProviderId[] = [];
|
||||
|
||||
set((state) => {
|
||||
if (epoch !== cliStatusEpoch || !state.cliStatus) {
|
||||
|
|
@ -771,6 +807,17 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
}
|
||||
|
||||
const nextCliStatus = mergeCliStatusPreservingHydratedProviders(state.cliStatus, metadata);
|
||||
const nextProviderLoading = Object.fromEntries(
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
|
||||
providerId,
|
||||
!isHydratedMultimodelProviderStatus(
|
||||
nextCliStatus.providers.find((provider) => provider.providerId === providerId)
|
||||
),
|
||||
])
|
||||
) as Partial<Record<CliProviderId, boolean>>;
|
||||
pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter(
|
||||
(providerId) => nextProviderLoading[providerId] === true
|
||||
);
|
||||
const nextAuthState = isMultimodelCliStatus(nextCliStatus)
|
||||
? buildMultimodelCliAuthState({
|
||||
status: nextCliStatus,
|
||||
|
|
@ -800,7 +847,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
return;
|
||||
}
|
||||
|
||||
if (pendingProviderIds.length === 0) {
|
||||
if (!hydrateProviders || pendingProviderIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -818,14 +865,16 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
}
|
||||
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) =>
|
||||
get().fetchCliProviderStatus(providerId, {
|
||||
silent: false,
|
||||
epoch,
|
||||
})
|
||||
)
|
||||
);
|
||||
if (hydrateProviders) {
|
||||
await Promise.allSettled(
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) =>
|
||||
get().fetchCliProviderStatus(providerId, {
|
||||
silent: false,
|
||||
epoch,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (epoch === cliStatusEpoch) {
|
||||
set({ cliStatusLoading: false });
|
||||
|
|
|
|||
|
|
@ -48,7 +48,13 @@ function getEmptyContextState(): Partial<AppState> {
|
|||
return {
|
||||
...getFullResetState(),
|
||||
projects: [],
|
||||
projectsLoading: false,
|
||||
projectsInitialized: false,
|
||||
projectsError: null,
|
||||
repositoryGroups: [],
|
||||
repositoryGroupsLoading: false,
|
||||
repositoryGroupsInitialized: false,
|
||||
repositoryGroupsError: null,
|
||||
sessions: [],
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
|
|
@ -148,8 +154,14 @@ function validateSnapshot(
|
|||
return {
|
||||
// Restored from snapshot (use fresh data for projects/repoGroups)
|
||||
projects: freshProjects,
|
||||
projectsLoading: false,
|
||||
projectsInitialized: true,
|
||||
projectsError: null,
|
||||
selectedProjectId,
|
||||
repositoryGroups: freshRepoGroups,
|
||||
repositoryGroupsLoading: false,
|
||||
repositoryGroupsInitialized: true,
|
||||
repositoryGroupsError: null,
|
||||
selectedRepositoryId,
|
||||
selectedWorktreeId,
|
||||
viewMode: snapshot.viewMode,
|
||||
|
|
@ -306,7 +318,13 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
|||
if (targetSnapshot) {
|
||||
set({
|
||||
projects: targetSnapshot.projects,
|
||||
projectsLoading: false,
|
||||
projectsInitialized: true,
|
||||
projectsError: null,
|
||||
repositoryGroups: targetSnapshot.repositoryGroups,
|
||||
repositoryGroupsLoading: false,
|
||||
repositoryGroupsInitialized: true,
|
||||
repositoryGroupsError: null,
|
||||
selectedProjectId: targetSnapshot.selectedProjectId,
|
||||
selectedRepositoryId: targetSnapshot.selectedRepositoryId,
|
||||
selectedWorktreeId: targetSnapshot.selectedWorktreeId,
|
||||
|
|
@ -360,7 +378,9 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
|||
set({
|
||||
...getEmptyContextState(),
|
||||
projects: freshProjects,
|
||||
projectsInitialized: true,
|
||||
repositoryGroups: freshRepoGroups,
|
||||
repositoryGroupsInitialized: true,
|
||||
activeContextId: targetContextId,
|
||||
isContextSwitching: false,
|
||||
targetContextId: null,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface ProjectSlice {
|
|||
projects: Project[];
|
||||
selectedProjectId: string | null;
|
||||
projectsLoading: boolean;
|
||||
projectsInitialized: boolean;
|
||||
projectsError: string | null;
|
||||
|
||||
// Actions
|
||||
|
|
@ -35,6 +36,7 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
|
|||
projects: [],
|
||||
selectedProjectId: null,
|
||||
projectsLoading: false,
|
||||
projectsInitialized: false,
|
||||
projectsError: null,
|
||||
|
||||
// Fetch all projects from main process
|
||||
|
|
@ -48,7 +50,7 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
|
|||
const sorted = [...projects].sort(
|
||||
(a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)
|
||||
);
|
||||
set({ projects: sorted, projectsLoading: false });
|
||||
set({ projects: sorted, projectsLoading: false, projectsInitialized: true });
|
||||
} catch (error) {
|
||||
set({
|
||||
projectsError: error instanceof Error ? error.message : 'Failed to fetch projects',
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface RepositorySlice {
|
|||
selectedRepositoryId: string | null;
|
||||
selectedWorktreeId: string | null;
|
||||
repositoryGroupsLoading: boolean;
|
||||
repositoryGroupsInitialized: boolean;
|
||||
repositoryGroupsError: string | null;
|
||||
viewMode: 'flat' | 'grouped';
|
||||
|
||||
|
|
@ -62,6 +63,7 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
|
|||
selectedRepositoryId: null,
|
||||
selectedWorktreeId: null,
|
||||
repositoryGroupsLoading: false,
|
||||
repositoryGroupsInitialized: false,
|
||||
repositoryGroupsError: null,
|
||||
viewMode: 'grouped', // Default to grouped view
|
||||
|
||||
|
|
@ -78,7 +80,11 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
|
|||
'get-repository-groups'
|
||||
);
|
||||
// Already sorted by most recent session in the scanner
|
||||
set({ repositoryGroups: groups, repositoryGroupsLoading: false });
|
||||
set({
|
||||
repositoryGroups: groups,
|
||||
repositoryGroupsLoading: false,
|
||||
repositoryGroupsInitialized: true,
|
||||
});
|
||||
const ms = Date.now() - startedAt;
|
||||
if (ms >= 2000) {
|
||||
logger.warn(`fetchRepositoryGroups slow ms=${ms} count=${groups.length}`);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
import type { CliInstallerProviderStatusMode } from '@shared/types';
|
||||
|
||||
interface RefreshCliStatusOptions {
|
||||
multimodelEnabled: boolean;
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
providerStatusMode?: CliInstallerProviderStatusMode;
|
||||
bootstrapCliStatus: (options?: {
|
||||
multimodelEnabled?: boolean;
|
||||
providerStatusMode?: CliInstallerProviderStatusMode;
|
||||
}) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
providerStatusMode,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
}: RefreshCliStatusOptions): Promise<void> {
|
||||
if (multimodelEnabled) {
|
||||
return bootstrapCliStatus({ multimodelEnabled: true });
|
||||
return bootstrapCliStatus(
|
||||
providerStatusMode
|
||||
? { multimodelEnabled: true, providerStatusMode }
|
||||
: { multimodelEnabled: true }
|
||||
);
|
||||
}
|
||||
|
||||
return fetchCliStatus();
|
||||
|
|
|
|||
94
src/renderer/utils/startupIdleTask.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
type StartupIdleTask = () => void;
|
||||
|
||||
interface StartupIdleDeadline {
|
||||
didTimeout: boolean;
|
||||
timeRemaining: () => number;
|
||||
}
|
||||
|
||||
type StartupIdleCallback = (deadline: StartupIdleDeadline) => void;
|
||||
|
||||
export interface StartupIdleTaskScheduler {
|
||||
setTimeout: typeof setTimeout;
|
||||
clearTimeout: typeof clearTimeout;
|
||||
requestIdleCallback?: (callback: StartupIdleCallback, options?: { timeout?: number }) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
}
|
||||
|
||||
export interface StartupIdleTaskOptions {
|
||||
minDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
scheduler?: StartupIdleTaskScheduler;
|
||||
}
|
||||
|
||||
function getDefaultStartupIdleTaskScheduler(): StartupIdleTaskScheduler {
|
||||
const timerHost = typeof window === 'undefined' ? globalThis : window;
|
||||
const idleWindow =
|
||||
typeof window === 'undefined'
|
||||
? null
|
||||
: (window as Window &
|
||||
typeof globalThis & {
|
||||
requestIdleCallback?: StartupIdleTaskScheduler['requestIdleCallback'];
|
||||
cancelIdleCallback?: StartupIdleTaskScheduler['cancelIdleCallback'];
|
||||
});
|
||||
|
||||
return {
|
||||
setTimeout: timerHost.setTimeout.bind(timerHost),
|
||||
clearTimeout: timerHost.clearTimeout.bind(timerHost),
|
||||
requestIdleCallback: idleWindow?.requestIdleCallback?.bind(idleWindow),
|
||||
cancelIdleCallback: idleWindow?.cancelIdleCallback?.bind(idleWindow),
|
||||
};
|
||||
}
|
||||
|
||||
export function scheduleStartupIdleTask(
|
||||
task: StartupIdleTask,
|
||||
options: StartupIdleTaskOptions
|
||||
): () => void {
|
||||
const scheduler = options.scheduler ?? getDefaultStartupIdleTaskScheduler();
|
||||
const minDelayMs = Math.max(0, options.minDelayMs);
|
||||
const maxDelayMs = Math.max(minDelayMs, options.maxDelayMs);
|
||||
let cancelled = false;
|
||||
let ran = false;
|
||||
let delayTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let idleHandle: number | null = null;
|
||||
|
||||
const runOnce = (): void => {
|
||||
if (cancelled || ran) {
|
||||
return;
|
||||
}
|
||||
ran = true;
|
||||
task();
|
||||
};
|
||||
|
||||
delayTimer = scheduler.setTimeout(() => {
|
||||
delayTimer = null;
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idleTimeoutMs = maxDelayMs - minDelayMs;
|
||||
if (scheduler.requestIdleCallback && idleTimeoutMs > 0) {
|
||||
idleHandle = scheduler.requestIdleCallback(
|
||||
() => {
|
||||
idleHandle = null;
|
||||
runOnce();
|
||||
},
|
||||
{ timeout: idleTimeoutMs }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runOnce();
|
||||
}, minDelayMs);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (delayTimer) {
|
||||
scheduler.clearTimeout(delayTimer);
|
||||
delayTimer = null;
|
||||
}
|
||||
if (idleHandle !== null && scheduler.cancelIdleCallback) {
|
||||
scheduler.cancelIdleCallback(idleHandle);
|
||||
idleHandle = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
33
src/renderer/vendor/radixComposeRefs.ts
vendored
|
|
@ -41,33 +41,8 @@ export function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T>
|
|||
}
|
||||
|
||||
export function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||
const refsRef = React.useRef(refs);
|
||||
refsRef.current = refs;
|
||||
|
||||
return React.useCallback((node) => {
|
||||
const currentRefs = refsRef.current;
|
||||
let hasCleanup = false;
|
||||
const cleanups = currentRefs.map((ref) => {
|
||||
const cleanup = setRef(ref, node);
|
||||
if (!hasCleanup && typeof cleanup === 'function') {
|
||||
hasCleanup = true;
|
||||
}
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
if (hasCleanup) {
|
||||
return () => {
|
||||
for (let index = 0; index < cleanups.length; index += 1) {
|
||||
const cleanup = cleanups[index];
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanup();
|
||||
} else {
|
||||
setRef(currentRefs[index], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, []);
|
||||
// This file is aliased over @radix-ui/react-compose-refs, so Radix imports this
|
||||
// export directly even though local source code does not reference it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Radix expects refs to be the dependency list.
|
||||
return React.useCallback(composeRefs(...refs), refs);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export type CliFlavor = 'claude' | 'agent_teams_orchestrator';
|
|||
|
||||
export type CliProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
|
||||
export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key';
|
||||
export const CLI_PROVIDER_STATUS_DEFERRED_MESSAGE = 'Provider status will refresh when needed.';
|
||||
|
||||
export interface CliProviderConnectionInfo {
|
||||
supportsOAuth: boolean;
|
||||
|
|
@ -351,6 +352,16 @@ export interface CliInstallerProgress {
|
|||
status?: CliInstallationStatus;
|
||||
}
|
||||
|
||||
export type CliInstallerProviderStatusMode = 'full' | 'defer';
|
||||
|
||||
export interface CliInstallerGetStatusOptions {
|
||||
/**
|
||||
* `defer` keeps startup lightweight by checking only the runtime binary/version.
|
||||
* Explicit refreshes should keep the default `full` mode.
|
||||
*/
|
||||
providerStatusMode?: CliInstallerProviderStatusMode;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Preload API
|
||||
// =============================================================================
|
||||
|
|
@ -360,7 +371,7 @@ export interface CliInstallerProgress {
|
|||
*/
|
||||
export interface CliInstallerAPI {
|
||||
/** Get current CLI installation status */
|
||||
getStatus: () => Promise<CliInstallationStatus>;
|
||||
getStatus: (options?: CliInstallerGetStatusOptions) => Promise<CliInstallationStatus>;
|
||||
/** Get current runtime/auth status for a single provider */
|
||||
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
|
||||
/** Start on-demand model verification for a single runtime provider */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useCodexAccountSnapshot } from '../../../../src/features/codex-account/renderer/hooks/useCodexAccountSnapshot';
|
||||
|
|
@ -15,6 +16,11 @@ const apiMocks = vi.hoisted(() => ({
|
|||
onCodexAccountSnapshotChanged: vi.fn(() => () => undefined),
|
||||
}));
|
||||
|
||||
type IdleCallbackForTest = (deadline: {
|
||||
didTimeout: boolean;
|
||||
timeRemaining: () => number;
|
||||
}) => void;
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: apiMocks,
|
||||
isElectronMode: () => true,
|
||||
|
|
@ -78,13 +84,16 @@ function createDeferred<T>() {
|
|||
describe('useCodexAccountSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
Reflect.deleteProperty(window, 'requestIdleCallback');
|
||||
Reflect.deleteProperty(window, 'cancelIdleCallback');
|
||||
});
|
||||
|
||||
it('loads the initial Codex snapshot through refresh when rate limits are requested', async () => {
|
||||
|
|
@ -124,9 +133,273 @@ describe('useCodexAccountSnapshot', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('can defer the initial Codex snapshot without starting interval refreshes first', async () => {
|
||||
vi.useFakeTimers();
|
||||
const snapshot = createSnapshot();
|
||||
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const state = useCodexAccountSnapshot({
|
||||
enabled: true,
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: 30_000,
|
||||
});
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-loading': state.loading ? 'true' : 'false' },
|
||||
state.snapshot?.managedAccount?.email ?? 'empty'
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||
expect(host.firstElementChild?.getAttribute('data-loading')).toBe('false');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(20_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({
|
||||
includeRateLimits: true,
|
||||
});
|
||||
expect(host.textContent).toContain('belief@example.com');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses idle scheduling for deferred initial Codex snapshots when a max delay is provided', async () => {
|
||||
vi.useFakeTimers();
|
||||
const snapshot = createSnapshot();
|
||||
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
|
||||
let idleCallback: IdleCallbackForTest = () => undefined;
|
||||
const requestIdleCallback = vi.fn((callback, options?: { timeout?: number }) => {
|
||||
idleCallback = callback;
|
||||
expect(options).toEqual({ timeout: 28_000 });
|
||||
return 7;
|
||||
});
|
||||
const cancelIdleCallback = vi.fn();
|
||||
Object.defineProperty(window, 'requestIdleCallback', {
|
||||
configurable: true,
|
||||
value: requestIdleCallback,
|
||||
});
|
||||
Object.defineProperty(window, 'cancelIdleCallback', {
|
||||
configurable: true,
|
||||
value: cancelIdleCallback,
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const state = useCodexAccountSnapshot({
|
||||
enabled: true,
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: 2_000,
|
||||
initialRefreshMaxDelayMs: 30_000,
|
||||
});
|
||||
|
||||
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_999);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||
expect(requestIdleCallback).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
idleCallback({ didTimeout: false, timeRemaining: () => 10 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledWith({
|
||||
includeRateLimits: true,
|
||||
});
|
||||
expect(host.textContent).toContain('belief@example.com');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
expect(cancelIdleCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a deferred initial Codex snapshot timer on unmount', async () => {
|
||||
vi.useFakeTimers();
|
||||
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(createSnapshot());
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
useCodexAccountSnapshot({
|
||||
enabled: true,
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: 30_000,
|
||||
});
|
||||
|
||||
return React.createElement('div', null, 'mounted');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps retrying after a deferred initial Codex snapshot fails transiently', async () => {
|
||||
vi.useFakeTimers();
|
||||
const snapshot = createSnapshot();
|
||||
apiMocks.refreshCodexAccountSnapshot
|
||||
.mockRejectedValueOnce(new Error('temporary Codex outage'))
|
||||
.mockResolvedValueOnce(snapshot);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const state = useCodexAccountSnapshot({
|
||||
enabled: true,
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: 30_000,
|
||||
});
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
null,
|
||||
state.error ?? state.snapshot?.managedAccount?.email ?? 'empty'
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).toContain('temporary Codex outage');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(host.textContent).toContain('belief@example.com');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not run the deferred initial snapshot after a manual refresh already loaded one', async () => {
|
||||
vi.useFakeTimers();
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => 'hidden' satisfies DocumentVisibilityState,
|
||||
});
|
||||
const snapshot = createSnapshot();
|
||||
apiMocks.refreshCodexAccountSnapshot.mockResolvedValue(snapshot);
|
||||
let refreshNow!: () => Promise<void>;
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const state = useCodexAccountSnapshot({
|
||||
enabled: true,
|
||||
includeRateLimits: true,
|
||||
initialRefreshDelayMs: 30_000,
|
||||
});
|
||||
refreshNow = () => state.refresh({ includeRateLimits: true });
|
||||
|
||||
return React.createElement('div', null, state.snapshot?.managedAccount?.email ?? 'empty');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await refreshNow();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.refreshCodexAccountSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes rate-limit snapshots more often while visible without flipping loading state during background polls', async () => {
|
||||
vi.useFakeTimers();
|
||||
let visibilityState: DocumentVisibilityState = 'visible';
|
||||
const visibilityState: DocumentVisibilityState = 'visible';
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => visibilityState,
|
||||
|
|
|
|||
|
|
@ -134,6 +134,62 @@ describe('electron-builder afterPack', () => {
|
|||
expect(fs.existsSync(path.join(prebuildsDir, 'darwin-x64'))).toBe(false);
|
||||
});
|
||||
|
||||
it('prunes host node-pty build outputs when a matching target prebuild exists', async () => {
|
||||
const tempDir = createTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const nodePtyDir = path.join(
|
||||
tempDir,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'node-pty'
|
||||
);
|
||||
const releaseDir = path.join(nodePtyDir, 'build', 'Release');
|
||||
const prebuildDir = path.join(nodePtyDir, 'prebuilds', 'win32-x64');
|
||||
|
||||
writeFile(path.join(tempDir, 'AgentTeamsAI.exe'), createPortableExecutableBuffer('x64'));
|
||||
writeFile(path.join(releaseDir, 'pty.node'), createMachOBuffer('arm64'));
|
||||
writeFile(path.join(releaseDir, 'spawn-helper'), createMachOBuffer('arm64'));
|
||||
writeFile(path.join(prebuildDir, 'pty.node'), createPortableExecutableBuffer('x64'));
|
||||
writeFile(path.join(prebuildDir, 'conpty.node'), createPortableExecutableBuffer('x64'));
|
||||
|
||||
await afterPackModule({
|
||||
appOutDir: tempDir,
|
||||
electronPlatformName: 'win32',
|
||||
arch: 1,
|
||||
});
|
||||
|
||||
expect(fs.existsSync(releaseDir)).toBe(false);
|
||||
expect(fs.existsSync(path.join(prebuildDir, 'pty.node'))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps incompatible node-pty build outputs when no target prebuild exists', async () => {
|
||||
const tempDir = createTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
const releaseDir = path.join(
|
||||
tempDir,
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'build',
|
||||
'Release'
|
||||
);
|
||||
|
||||
writeFile(path.join(tempDir, 'agent-teams-ai'), createElfBuffer('x64'));
|
||||
writeFile(path.join(releaseDir, 'pty.node'), createMachOBuffer('arm64'));
|
||||
|
||||
await expect(
|
||||
afterPackModule({
|
||||
appOutDir: tempDir,
|
||||
electronPlatformName: 'linux',
|
||||
arch: 1,
|
||||
})
|
||||
).rejects.toThrow('Found incompatible native binaries in linux-x64 bundle after pruning');
|
||||
|
||||
expect(fs.existsSync(releaseDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails validation when a foreign-arch native binary remains in the bundle', async () => {
|
||||
const tempDir = createTempDir();
|
||||
tempDirs.push(tempDir);
|
||||
|
|
|
|||
|
|
@ -1380,6 +1380,7 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
|
|||
],
|
||||
expect.objectContaining({ cwd: '/Users/test/project' })
|
||||
);
|
||||
expect(execCliMock.mock.calls[0]?.[2]).toMatchObject({ maxBuffer: 8 * 1024 * 1024 });
|
||||
expect(JSON.stringify(execCliMock.mock.calls[0])).not.toContain('undefined');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -275,6 +275,161 @@ describe('cliInstaller IPC handlers', () => {
|
|||
expect(cached.data?.providers[0]?.statusMessage).toBe('ChatGPT account ready');
|
||||
});
|
||||
|
||||
it('keeps lightweight startup status cache separate from full provider status cache', async () => {
|
||||
const deferredStartupStatus = status([
|
||||
provider({
|
||||
providerId: 'anthropic',
|
||||
supported: false,
|
||||
statusMessage: 'Provider status will refresh when needed.',
|
||||
}),
|
||||
provider({
|
||||
providerId: 'codex',
|
||||
supported: false,
|
||||
statusMessage: 'Provider status will refresh when needed.',
|
||||
}),
|
||||
]);
|
||||
const fullStatus = status([
|
||||
provider({
|
||||
providerId: 'anthropic',
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Connected',
|
||||
}),
|
||||
provider({
|
||||
providerId: 'codex',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
}),
|
||||
]);
|
||||
const startupRequest = deferred<CliInstallationStatus>();
|
||||
const fullRequest = deferred<CliInstallationStatus>();
|
||||
service.getStatus.mockImplementation((options?: { providerStatusMode?: string }) =>
|
||||
options?.providerStatusMode === 'defer' ? startupRequest.promise : fullRequest.promise
|
||||
);
|
||||
|
||||
const startupInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS, {
|
||||
providerStatusMode: 'defer',
|
||||
}) as Promise<IpcResult<CliInstallationStatus>>;
|
||||
const fullInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise<
|
||||
IpcResult<CliInstallationStatus>
|
||||
>;
|
||||
await vi.waitFor(() => expect(service.getStatus).toHaveBeenCalledTimes(2));
|
||||
|
||||
startupRequest.resolve(deferredStartupStatus);
|
||||
fullRequest.resolve(fullStatus);
|
||||
|
||||
await expect(startupInvoke).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { authLoggedIn: false },
|
||||
});
|
||||
await expect(fullInvoke).resolves.toMatchObject({
|
||||
success: true,
|
||||
data: { authLoggedIn: true, authMethod: 'oauth_token' },
|
||||
});
|
||||
|
||||
const cachedStartup = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS, {
|
||||
providerStatusMode: 'defer',
|
||||
})) as IpcResult<CliInstallationStatus>;
|
||||
const cachedFull = (await ipcMain.invoke(
|
||||
CLI_INSTALLER_GET_STATUS
|
||||
)) as IpcResult<CliInstallationStatus>;
|
||||
|
||||
expect(service.getStatus).toHaveBeenCalledTimes(2);
|
||||
expect(cachedStartup.data?.authLoggedIn).toBe(false);
|
||||
expect(cachedStartup.data?.providers[0]?.statusMessage).toBe(
|
||||
'Provider status will refresh when needed.'
|
||||
);
|
||||
expect(cachedFull.data?.authLoggedIn).toBe(true);
|
||||
expect(cachedFull.data?.providers[1]?.statusMessage).toBe('ChatGPT account ready');
|
||||
});
|
||||
|
||||
it('does not replace a cached full provider status with a deferred startup snapshot', async () => {
|
||||
const fullStatus = status([
|
||||
provider({
|
||||
providerId: 'anthropic',
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Connected',
|
||||
}),
|
||||
]);
|
||||
const deferredStartupStatus = status([
|
||||
provider({
|
||||
providerId: 'anthropic',
|
||||
supported: false,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Provider status will refresh when needed.',
|
||||
}),
|
||||
]);
|
||||
service.getStatus.mockResolvedValueOnce(fullStatus);
|
||||
|
||||
const first = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
|
||||
expect(first.success).toBe(true);
|
||||
expect(first.data?.providers[0]?.statusMessage).toBe('Connected');
|
||||
|
||||
service.getLatestStatusSnapshot.mockReturnValue(deferredStartupStatus);
|
||||
const cached = (await ipcMain.invoke(
|
||||
CLI_INSTALLER_GET_STATUS
|
||||
)) as IpcResult<CliInstallationStatus>;
|
||||
|
||||
expect(service.getStatus).toHaveBeenCalledTimes(1);
|
||||
expect(cached.success).toBe(true);
|
||||
expect(cached.data?.authLoggedIn).toBe(true);
|
||||
expect(cached.data?.providers[0]?.statusMessage).toBe('Connected');
|
||||
});
|
||||
|
||||
it('does not cache incomplete full provider status responses', async () => {
|
||||
const incompleteFullStatus = status([
|
||||
provider({
|
||||
providerId: 'anthropic',
|
||||
supported: false,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Provider status will refresh when needed.',
|
||||
}),
|
||||
provider({
|
||||
providerId: 'codex',
|
||||
supported: false,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
const freshFullStatus = status([
|
||||
provider({
|
||||
providerId: 'anthropic',
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Connected',
|
||||
}),
|
||||
provider({
|
||||
providerId: 'codex',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
}),
|
||||
]);
|
||||
incompleteFullStatus.authStatusChecking = true;
|
||||
service.getStatus
|
||||
.mockResolvedValueOnce(incompleteFullStatus)
|
||||
.mockResolvedValueOnce(freshFullStatus);
|
||||
|
||||
const first = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
|
||||
const second = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
|
||||
|
||||
expect(service.getStatus).toHaveBeenCalledTimes(2);
|
||||
expect(first.success).toBe(true);
|
||||
expect(first.data?.providers[0]?.statusMessage).toBe(
|
||||
'Provider status will refresh when needed.'
|
||||
);
|
||||
expect(second.success).toBe(true);
|
||||
expect(second.data?.authLoggedIn).toBe(true);
|
||||
expect(second.data?.providers[0]?.statusMessage).toBe('Connected');
|
||||
});
|
||||
|
||||
it('does not let a stale in-flight provider refresh patch the cache after invalidation', async () => {
|
||||
const staleProviderRequest = deferred<CliProviderStatus | null>();
|
||||
service.getStatus
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { realpathMock } = vi.hoisted(() => ({
|
||||
const { realpathMock, resolveInteractiveShellEnvBestEffortMock } = vi.hoisted(() => ({
|
||||
realpathMock: vi.fn(async (value: string) => value),
|
||||
resolveInteractiveShellEnvBestEffortMock: vi.fn(
|
||||
async (options?: { fallbackEnv?: NodeJS.ProcessEnv }) => options?.fallbackEnv ?? process.env
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock dependencies before importing service
|
||||
|
|
@ -83,6 +86,14 @@ vi.mock('@main/utils/cliAuthDiagLog', () => ({
|
|||
appendCliAuthDiag: vi.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/shellEnv', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@main/utils/shellEnv')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveInteractiveShellEnvBestEffort: resolveInteractiveShellEnvBestEffortMock,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
CliInstallerService,
|
||||
isVersionOlder,
|
||||
|
|
@ -148,6 +159,9 @@ describe('CliInstallerService', () => {
|
|||
vi.clearAllMocks();
|
||||
realpathMock.mockReset();
|
||||
realpathMock.mockImplementation(async (value: string) => value);
|
||||
resolveInteractiveShellEnvBestEffortMock.mockImplementation(
|
||||
async (options?: { fallbackEnv?: NodeJS.ProcessEnv }) => options?.fallbackEnv ?? process.env
|
||||
);
|
||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude');
|
||||
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||
displayName: 'Claude CLI',
|
||||
|
|
@ -169,6 +183,13 @@ describe('CliInstallerService', () => {
|
|||
expect(status.installedVersion).toBeNull();
|
||||
expect(status.binaryPath).toBeNull();
|
||||
expect(status.updateAvailable).toBe(false);
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_500,
|
||||
fallbackEnv: process.env,
|
||||
background: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not block getStatus on diagnostic file writes', async () => {
|
||||
|
|
@ -325,6 +346,71 @@ describe('CliInstallerService', () => {
|
|||
expect(service.getLatestStatusSnapshot()?.authLoggedIn).toBe(false);
|
||||
});
|
||||
|
||||
it('defers multimodel provider status probes during lightweight startup status checks', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
|
||||
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
|
||||
displayName: 'agent_teams_orchestrator',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
});
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/agent_teams_orchestrator');
|
||||
vi.mocked(execCli).mockResolvedValueOnce({ stdout: '0.0.46', stderr: '' });
|
||||
const getProviderStatusesSpy = vi
|
||||
.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses')
|
||||
.mockResolvedValue([
|
||||
createTestProviderStatus('anthropic', true, 'oauth_token'),
|
||||
createTestProviderStatus('codex', true, 'chatgpt'),
|
||||
createTestProviderStatus('opencode', true, 'opencode_managed'),
|
||||
]);
|
||||
const mockWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn(), isDestroyed: () => false },
|
||||
};
|
||||
service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);
|
||||
|
||||
const status = await service.getStatus({ providerStatusMode: 'defer' });
|
||||
const statusEvents = mockWindow.webContents.send.mock.calls
|
||||
.filter((call: unknown[]) => call[0] === 'cliInstaller:progress')
|
||||
.map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } })
|
||||
.filter((event) => event.type === 'status');
|
||||
|
||||
expect(status.installed).toBe(true);
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||
expect(status.authStatusChecking).toBe(false);
|
||||
expect(status.authLoggedIn).toBe(false);
|
||||
expect(status.providers).toHaveLength(3);
|
||||
expect(
|
||||
status.providers.every(
|
||||
(provider) => provider.statusMessage === 'Provider status will refresh when needed.'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(statusEvents.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
statusEvents.every((event) =>
|
||||
event.status?.providers?.every(
|
||||
(provider) =>
|
||||
typeof provider === 'object' &&
|
||||
provider !== null &&
|
||||
'statusMessage' in provider &&
|
||||
'models' in provider &&
|
||||
(provider as { statusMessage?: string }).statusMessage ===
|
||||
'Provider status will refresh when needed.' &&
|
||||
Array.isArray((provider as { models?: unknown[] }).models) &&
|
||||
(provider as { models?: unknown[] }).models?.length === 0
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(getProviderStatusesSpy).not.toHaveBeenCalled();
|
||||
expect(execCli).toHaveBeenCalledTimes(1);
|
||||
expect(execCli).toHaveBeenCalledWith(
|
||||
'/mock/agent_teams_orchestrator',
|
||||
['--version'],
|
||||
expect.objectContaining({ timeout: expect.any(Number) })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,598 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
CrossPlatformFileChangeSource,
|
||||
type WatcherLifecycle,
|
||||
} from '../../../../src/main/services/infrastructure/CrossPlatformFileChangeSource';
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function createSource(options: {
|
||||
active: () => boolean;
|
||||
createWatcher?: (
|
||||
lifecycle: WatcherLifecycle
|
||||
) => Promise<{ close: () => void }> | { close: () => void };
|
||||
collectPollSnapshot?: () => Promise<Map<string, string>>;
|
||||
}) {
|
||||
return new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: options.createWatcher,
|
||||
collectPollSnapshot: options.collectPollSnapshot ?? vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: options.active,
|
||||
isWatchLimitError: (error) => (error as NodeJS.ErrnoException | undefined)?.code === 'EMFILE',
|
||||
requestRetry: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe('CrossPlatformFileChangeSource', () => {
|
||||
it('coalesces concurrent watcher starts into one watcher', async () => {
|
||||
let active = true;
|
||||
let resolveWatcher: ((watcher: { close: () => void }) => void) | undefined;
|
||||
const close = vi.fn();
|
||||
const createWatcher = vi.fn(
|
||||
() =>
|
||||
new Promise<{ close: () => void }>((resolve) => {
|
||||
resolveWatcher = resolve;
|
||||
})
|
||||
);
|
||||
const source = createSource({ active: () => active, createWatcher });
|
||||
|
||||
const firstStart = source.start();
|
||||
const secondStart = source.start();
|
||||
expect(createWatcher).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveWatcher?.({ close });
|
||||
await Promise.all([firstStart, secondStart]);
|
||||
|
||||
expect(source.isActive).toBe(true);
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('ignores stale watcher close events after restart', async () => {
|
||||
let active = true;
|
||||
const lifecycles: WatcherLifecycle[] = [];
|
||||
const createWatcher = vi.fn((lifecycle: WatcherLifecycle) => {
|
||||
lifecycles.push(lifecycle);
|
||||
return { close: vi.fn() };
|
||||
});
|
||||
const source = createSource({ active: () => active, createWatcher });
|
||||
|
||||
await source.start();
|
||||
source.stop();
|
||||
await source.start();
|
||||
|
||||
lifecycles[0].onClose();
|
||||
|
||||
expect(source.isActive).toBe(true);
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('marks old watcher lifecycles stale after restart', async () => {
|
||||
let active = true;
|
||||
const lifecycles: WatcherLifecycle[] = [];
|
||||
const createWatcher = vi.fn((lifecycle: WatcherLifecycle) => {
|
||||
lifecycles.push(lifecycle);
|
||||
return { close: vi.fn() };
|
||||
});
|
||||
const source = createSource({ active: () => active, createWatcher });
|
||||
|
||||
await source.start();
|
||||
expect(lifecycles[0].isCurrent()).toBe(true);
|
||||
|
||||
source.stop();
|
||||
expect(lifecycles[0].isCurrent()).toBe(false);
|
||||
|
||||
await source.start();
|
||||
expect(lifecycles[0].isCurrent()).toBe(false);
|
||||
expect(lifecycles[1].isCurrent()).toBe(true);
|
||||
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('does not keep a watcher that closes during startup', async () => {
|
||||
let active = true;
|
||||
let lifecycle: WatcherLifecycle | undefined;
|
||||
const close = vi.fn();
|
||||
const requestRetry = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn((nextLifecycle: WatcherLifecycle) => {
|
||||
lifecycle = nextLifecycle;
|
||||
lifecycle.onClose();
|
||||
return { close };
|
||||
}),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry,
|
||||
});
|
||||
|
||||
await source.start();
|
||||
|
||||
expect(source.isActive).toBe(false);
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(requestRetry).toHaveBeenCalledTimes(1);
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('falls back to polling when a watcher reports a limit error during startup', async () => {
|
||||
let active = true;
|
||||
let lifecycle: WatcherLifecycle | undefined;
|
||||
const close = vi.fn();
|
||||
const requestRetry = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn((nextLifecycle: WatcherLifecycle) => {
|
||||
lifecycle = nextLifecycle;
|
||||
lifecycle.onError(Object.assign(new Error('too many open files'), { code: 'EMFILE' }));
|
||||
return { close };
|
||||
}),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: (error) => (error as NodeJS.ErrnoException | undefined)?.code === 'EMFILE',
|
||||
requestRetry,
|
||||
});
|
||||
|
||||
await source.start();
|
||||
|
||||
expect(source.currentPollingTimer).not.toBeNull();
|
||||
expect(lifecycle?.isCurrent()).toBe(false);
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(requestRetry).not.toHaveBeenCalled();
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('falls back to polling when startup closes before throwing a limit error', async () => {
|
||||
let active = true;
|
||||
let lifecycle: WatcherLifecycle | undefined;
|
||||
const requestRetry = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn((nextLifecycle: WatcherLifecycle) => {
|
||||
lifecycle = nextLifecycle;
|
||||
lifecycle.onClose();
|
||||
throw Object.assign(new Error('too many open files'), { code: 'EMFILE' });
|
||||
}),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: (error) => (error as NodeJS.ErrnoException | undefined)?.code === 'EMFILE',
|
||||
requestRetry,
|
||||
});
|
||||
|
||||
await source.start();
|
||||
|
||||
expect(source.currentPollingTimer).not.toBeNull();
|
||||
expect(lifecycle?.isCurrent()).toBe(false);
|
||||
expect(requestRetry).toHaveBeenCalledTimes(1);
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('does not retry when startup throws after reporting a limit error', async () => {
|
||||
let active = true;
|
||||
const requestRetry = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn((lifecycle: WatcherLifecycle) => {
|
||||
lifecycle.onError(Object.assign(new Error('too many open files'), { code: 'EMFILE' }));
|
||||
throw Object.assign(new Error('startup failed after limit error'), { code: 'EMFILE' });
|
||||
}),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: (error) => (error as NodeJS.ErrnoException | undefined)?.code === 'EMFILE',
|
||||
requestRetry,
|
||||
});
|
||||
|
||||
await source.start();
|
||||
|
||||
expect(source.currentPollingTimer).not.toBeNull();
|
||||
expect(requestRetry).not.toHaveBeenCalled();
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('retries without keeping a watcher that reports a non-limit error during startup', async () => {
|
||||
let active = true;
|
||||
let lifecycle: WatcherLifecycle | undefined;
|
||||
const close = vi.fn();
|
||||
const requestRetry = vi.fn();
|
||||
const onWatcherError = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn((nextLifecycle: WatcherLifecycle) => {
|
||||
lifecycle = nextLifecycle;
|
||||
lifecycle.onError(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
|
||||
return { close };
|
||||
}),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: (error) => (error as NodeJS.ErrnoException | undefined)?.code === 'EMFILE',
|
||||
requestRetry,
|
||||
onWatcherError,
|
||||
});
|
||||
|
||||
await source.start();
|
||||
|
||||
expect(source.isActive).toBe(false);
|
||||
expect(lifecycle?.isCurrent()).toBe(false);
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(onWatcherError).toHaveBeenCalledTimes(1);
|
||||
expect(requestRetry).toHaveBeenCalledTimes(1);
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('does not retry twice when startup throws after reporting a non-limit error', async () => {
|
||||
let active = true;
|
||||
const requestRetry = vi.fn();
|
||||
const onWatcherError = vi.fn();
|
||||
const onWatcherStartError = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn((lifecycle: WatcherLifecycle) => {
|
||||
lifecycle.onError(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
|
||||
throw Object.assign(new Error('startup failed after permission error'), { code: 'EACCES' });
|
||||
}),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: (error) => (error as NodeJS.ErrnoException | undefined)?.code === 'EMFILE',
|
||||
requestRetry,
|
||||
onWatcherError,
|
||||
onWatcherStartError,
|
||||
});
|
||||
|
||||
await source.start();
|
||||
|
||||
expect(source.isActive).toBe(false);
|
||||
expect(onWatcherError).toHaveBeenCalledTimes(1);
|
||||
expect(onWatcherStartError).not.toHaveBeenCalled();
|
||||
expect(requestRetry).toHaveBeenCalledTimes(1);
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('invalidates startup lifecycles after a direct non-limit start failure', async () => {
|
||||
let active = true;
|
||||
let lifecycle: WatcherLifecycle | undefined;
|
||||
const requestRetry = vi.fn();
|
||||
const onWatcherError = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn((nextLifecycle: WatcherLifecycle) => {
|
||||
lifecycle = nextLifecycle;
|
||||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
}),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: (error) => (error as NodeJS.ErrnoException | undefined)?.code === 'EMFILE',
|
||||
requestRetry,
|
||||
onWatcherError,
|
||||
});
|
||||
|
||||
await source.start();
|
||||
|
||||
expect(source.isActive).toBe(false);
|
||||
expect(lifecycle?.isCurrent()).toBe(false);
|
||||
expect(requestRetry).toHaveBeenCalledTimes(1);
|
||||
|
||||
lifecycle?.onError(Object.assign(new Error('too many open files'), { code: 'EMFILE' }));
|
||||
lifecycle?.onClose();
|
||||
|
||||
expect(source.currentPollingTimer).toBeNull();
|
||||
expect(onWatcherError).not.toHaveBeenCalled();
|
||||
expect(requestRetry).toHaveBeenCalledTimes(1);
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('does not request retry twice when a watcher closes after an error', async () => {
|
||||
let active = true;
|
||||
let lifecycle: WatcherLifecycle | undefined;
|
||||
const close = vi.fn();
|
||||
const requestRetry = vi.fn();
|
||||
const onWatcherError = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn((nextLifecycle: WatcherLifecycle) => {
|
||||
lifecycle = nextLifecycle;
|
||||
return { close };
|
||||
}),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry,
|
||||
onWatcherError,
|
||||
});
|
||||
|
||||
await source.start();
|
||||
lifecycle?.onError(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
|
||||
lifecycle?.onClose();
|
||||
|
||||
expect(source.isActive).toBe(false);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(onWatcherError).toHaveBeenCalledTimes(1);
|
||||
expect(requestRetry).toHaveBeenCalledTimes(1);
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('falls back to polling when a close is followed by a limit error', async () => {
|
||||
let active = true;
|
||||
let lifecycle: WatcherLifecycle | undefined;
|
||||
const requestRetry = vi.fn();
|
||||
const onWatcherError = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn((nextLifecycle: WatcherLifecycle) => {
|
||||
lifecycle = nextLifecycle;
|
||||
return { close: vi.fn() };
|
||||
}),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: (error) => (error as NodeJS.ErrnoException | undefined)?.code === 'EMFILE',
|
||||
requestRetry,
|
||||
onWatcherError,
|
||||
});
|
||||
|
||||
await source.start();
|
||||
lifecycle?.onClose();
|
||||
lifecycle?.onError(Object.assign(new Error('too many open files'), { code: 'EMFILE' }));
|
||||
|
||||
expect(source.currentPollingTimer).not.toBeNull();
|
||||
expect(onWatcherError).not.toHaveBeenCalled();
|
||||
expect(requestRetry).toHaveBeenCalledTimes(1);
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('does not request retry when switching an active watcher to polling', async () => {
|
||||
let active = true;
|
||||
let lifecycle: WatcherLifecycle | undefined;
|
||||
const requestRetry = vi.fn();
|
||||
const close = vi.fn(() => lifecycle?.onClose());
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn((nextLifecycle: WatcherLifecycle) => {
|
||||
lifecycle = nextLifecycle;
|
||||
return { close };
|
||||
}),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry,
|
||||
});
|
||||
|
||||
await source.start();
|
||||
source.startPolling();
|
||||
|
||||
expect(source.currentPollingTimer).not.toBeNull();
|
||||
expect(lifecycle?.isCurrent()).toBe(false);
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(requestRetry).not.toHaveBeenCalled();
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('closes a late watcher when polling starts during watcher startup', async () => {
|
||||
let active = true;
|
||||
let resolveWatcher: ((watcher: { close: () => void }) => void) | undefined;
|
||||
const close = vi.fn();
|
||||
const lifecycles: WatcherLifecycle[] = [];
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn(
|
||||
(lifecycle: WatcherLifecycle) =>
|
||||
new Promise<{ close: () => void }>((resolve) => {
|
||||
lifecycles.push(lifecycle);
|
||||
resolveWatcher = resolve;
|
||||
})
|
||||
),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry: vi.fn(),
|
||||
});
|
||||
|
||||
const start = source.start();
|
||||
expect(lifecycles).toHaveLength(1);
|
||||
expect(lifecycles[0].isCurrent()).toBe(true);
|
||||
|
||||
source.startPolling();
|
||||
expect(source.currentPollingTimer).not.toBeNull();
|
||||
expect(lifecycles[0].isCurrent()).toBe(false);
|
||||
|
||||
resolveWatcher?.({ close });
|
||||
await start;
|
||||
|
||||
expect(close).toHaveBeenCalled();
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('closes a stale pending watcher after stop and restart', async () => {
|
||||
let active = true;
|
||||
const resolvers: Array<(watcher: { close: () => void }) => void> = [];
|
||||
const closeOld = vi.fn();
|
||||
const closeCurrent = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn(
|
||||
() =>
|
||||
new Promise<{ close: () => void }>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
})
|
||||
),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry: vi.fn(),
|
||||
});
|
||||
|
||||
const firstStart = source.start();
|
||||
source.stop();
|
||||
const secondStart = source.start();
|
||||
|
||||
resolvers[1]?.({ close: closeCurrent });
|
||||
await secondStart;
|
||||
expect(source.isActive).toBe(true);
|
||||
|
||||
resolvers[0]?.({ close: closeOld });
|
||||
await firstStart;
|
||||
|
||||
expect(closeOld).toHaveBeenCalledTimes(1);
|
||||
expect(closeCurrent).not.toHaveBeenCalled();
|
||||
expect(source.isActive).toBe(true);
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('swallows synchronous watcher close failures during stale startup cleanup', async () => {
|
||||
let active = true;
|
||||
let resolveWatcher: ((watcher: { close: () => void }) => void) | undefined;
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
createWatcher: vi.fn(
|
||||
() =>
|
||||
new Promise<{ close: () => void }>((resolve) => {
|
||||
resolveWatcher = resolve;
|
||||
})
|
||||
),
|
||||
collectPollSnapshot: vi.fn().mockResolvedValue(new Map()),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry: vi.fn(),
|
||||
});
|
||||
|
||||
const start = source.start();
|
||||
source.stop();
|
||||
resolveWatcher?.({
|
||||
close: () => {
|
||||
throw new Error('close failed');
|
||||
},
|
||||
});
|
||||
|
||||
await expect(start).resolves.toBeUndefined();
|
||||
expect(source.isActive).toBe(false);
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('ignores stale in-flight polling snapshots after stop and restart', async () => {
|
||||
let active = true;
|
||||
const snapshots: Array<() => void> = [];
|
||||
const emitted: string[] = [];
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
collectPollSnapshot: () =>
|
||||
new Promise<Map<string, string>>((resolve) => {
|
||||
snapshots.push(() => resolve(new Map([['old.json', '1']])));
|
||||
}),
|
||||
emitPolledChange: (_eventType, relativePath) => emitted.push(relativePath),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry: vi.fn(),
|
||||
});
|
||||
|
||||
source.startPolling();
|
||||
expect(snapshots).toHaveLength(1);
|
||||
source.stop();
|
||||
source.startPolling();
|
||||
expect(snapshots).toHaveLength(2);
|
||||
|
||||
snapshots[0]();
|
||||
await Promise.resolve();
|
||||
snapshots[1]();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(emitted).toEqual([]);
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
|
||||
it('ignores stale in-flight polling errors after stop', async () => {
|
||||
let active = true;
|
||||
const onPollingError = vi.fn();
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
collectPollSnapshot: vi.fn().mockRejectedValue(new Error('old polling failure')),
|
||||
emitPolledChange: vi.fn(),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry: vi.fn(),
|
||||
onPollingError,
|
||||
});
|
||||
|
||||
const poll = source.pollOnce();
|
||||
source.stop();
|
||||
active = false;
|
||||
await poll;
|
||||
|
||||
expect(onPollingError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the previous polling snapshot when a poll fails', async () => {
|
||||
let active = true;
|
||||
const emitted: Array<[string, string]> = [];
|
||||
const onPollingError = vi.fn();
|
||||
const collectPollSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Map([['session.jsonl', '1']]))
|
||||
.mockRejectedValueOnce(new Error('transient polling failure'))
|
||||
.mockResolvedValueOnce(new Map([['session.jsonl', '2']]));
|
||||
const source = new CrossPlatformFileChangeSource({
|
||||
name: 'test-source',
|
||||
pollIntervalMs: 1000,
|
||||
collectPollSnapshot,
|
||||
emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]),
|
||||
isOwnerActive: () => active,
|
||||
isWatchLimitError: () => false,
|
||||
requestRetry: vi.fn(),
|
||||
onPollingError,
|
||||
});
|
||||
|
||||
await source.pollOnce();
|
||||
await source.pollOnce();
|
||||
await source.pollOnce();
|
||||
|
||||
expect(onPollingError).toHaveBeenCalledTimes(1);
|
||||
expect(emitted).toEqual([['change', 'session.jsonl']]);
|
||||
source.stop();
|
||||
active = false;
|
||||
});
|
||||
});
|
||||
|
|
@ -168,6 +168,61 @@ function createMockNotificationManager() {
|
|||
} as unknown as Parameters<FileWatcher['setNotificationManager']>[0];
|
||||
}
|
||||
|
||||
function createFsDirent(
|
||||
name: string,
|
||||
type: 'file' | 'directory',
|
||||
metadata: { size?: number; mtimeMs?: number } = {}
|
||||
) {
|
||||
return {
|
||||
name,
|
||||
...metadata,
|
||||
isFile: () => type === 'file',
|
||||
isDirectory: () => type === 'directory',
|
||||
};
|
||||
}
|
||||
|
||||
type TestChangeSourceName = 'projects' | 'todos' | 'teams' | 'tasks';
|
||||
interface TestChangeSourceState {
|
||||
currentPollingTimer: NodeJS.Timeout | null;
|
||||
isPollingPrimed: boolean;
|
||||
pollOnce: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getChangeSource(watcher: FileWatcher, name: TestChangeSourceName): TestChangeSourceState {
|
||||
return (
|
||||
watcher as unknown as {
|
||||
changeSources: Record<TestChangeSourceName, TestChangeSourceState>;
|
||||
}
|
||||
).changeSources[name];
|
||||
}
|
||||
|
||||
function getRetryTimer(watcher: FileWatcher): NodeJS.Timeout | null {
|
||||
return (watcher as unknown as { retryTimer: NodeJS.Timeout | null }).retryTimer;
|
||||
}
|
||||
|
||||
function setWatcherActive(watcher: FileWatcher): void {
|
||||
(watcher as unknown as { isWatching: boolean }).isWatching = true;
|
||||
}
|
||||
|
||||
type NativeWatchCallback = (eventType: string, filename: string) => void;
|
||||
|
||||
function getNativeWatchCallback(
|
||||
optionsOrListener: unknown,
|
||||
maybeListener: unknown
|
||||
): NativeWatchCallback | undefined {
|
||||
return (
|
||||
typeof optionsOrListener === 'function' ? optionsOrListener : maybeListener
|
||||
) as NativeWatchCallback | undefined;
|
||||
}
|
||||
|
||||
function mockFsWatchImplementation(implementation: (...args: unknown[]) => FsType.FSWatcher): void {
|
||||
(
|
||||
vi.mocked(fs.watch) as unknown as {
|
||||
mockImplementation: (nextImplementation: (...args: unknown[]) => FsType.FSWatcher) => void;
|
||||
}
|
||||
).mockImplementation(implementation);
|
||||
}
|
||||
|
||||
/** Helper to write a valid JSONL line */
|
||||
function jsonlLine(uuid: string, text: string): string {
|
||||
return (
|
||||
|
|
@ -256,6 +311,659 @@ describe('FileWatcher', () => {
|
|||
watcher.stop();
|
||||
});
|
||||
|
||||
it('ignores stale native watcher callbacks after stop and restart', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-native-stale-'));
|
||||
setClaudeBasePathOverride(tempDir);
|
||||
const projectsDir = path.join(tempDir, 'projects');
|
||||
const todosDir = path.join(tempDir, 'todos');
|
||||
const projectDir = path.join(projectsDir, 'encoded-project');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.mkdirSync(todosDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'teams', 'base-1', 'inboxes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'tasks', 'base-1'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectDir, 'old-session.jsonl'), jsonlLine('old', 'old'), 'utf8');
|
||||
fs.writeFileSync(path.join(projectDir, 'new-session.jsonl'), jsonlLine('new', 'new'), 'utf8');
|
||||
fs.writeFileSync(path.join(todosDir, 'old-todo.json'), '{"items":[]}', 'utf8');
|
||||
fs.writeFileSync(path.join(todosDir, 'new-todo.json'), '{"items":[]}', 'utf8');
|
||||
useRealAccess();
|
||||
|
||||
const projectCallbacks: Array<(eventType: string, filename: string) => void> = [];
|
||||
const todoCallbacks: Array<(eventType: string, filename: string) => void> = [];
|
||||
mockFsWatchImplementation((targetPath, optionsOrListener, maybeListener) => {
|
||||
const listener = getNativeWatchCallback(optionsOrListener, maybeListener);
|
||||
if (String(targetPath) === projectsDir && listener) {
|
||||
projectCallbacks.push(listener);
|
||||
} else if (String(targetPath) === todosDir && listener) {
|
||||
todoCallbacks.push(listener);
|
||||
}
|
||||
return createFakeWatcher();
|
||||
});
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir);
|
||||
const fileEvents: unknown[] = [];
|
||||
const todoEvents: unknown[] = [];
|
||||
watcher.on('file-change', (event) => fileEvents.push(event));
|
||||
watcher.on('todo-change', (event) => todoEvents.push(event));
|
||||
|
||||
watcher.start();
|
||||
await vi.waitFor(() => {
|
||||
expect(projectCallbacks).toHaveLength(1);
|
||||
expect(todoCallbacks).toHaveLength(1);
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
watcher.start();
|
||||
await vi.waitFor(() => {
|
||||
expect(projectCallbacks).toHaveLength(2);
|
||||
expect(todoCallbacks).toHaveLength(2);
|
||||
});
|
||||
|
||||
projectCallbacks[0]('rename', 'encoded-project/old-session.jsonl');
|
||||
todoCallbacks[0]('rename', 'old-todo.json');
|
||||
projectCallbacks[1]('rename', 'encoded-project/new-session.jsonl');
|
||||
todoCallbacks[1]('rename', 'new-todo.json');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fileEvents).toEqual([
|
||||
{
|
||||
type: 'add',
|
||||
path: path.join(projectDir, 'new-session.jsonl'),
|
||||
projectId: 'encoded-project',
|
||||
sessionId: 'new-session',
|
||||
isSubagent: false,
|
||||
},
|
||||
]);
|
||||
expect(todoEvents).toEqual([
|
||||
{
|
||||
type: 'add',
|
||||
path: path.join(todosDir, 'new-todo.json'),
|
||||
sessionId: 'new-todo',
|
||||
isSubagent: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('falls back to projects polling on EMFILE and still emits session and subagent changes', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-project-emfile-'));
|
||||
setClaudeBasePathOverride(tempDir);
|
||||
const projectsDir = path.join(tempDir, 'projects');
|
||||
const todosDir = path.join(tempDir, 'todos');
|
||||
const teamsDir = path.join(tempDir, 'teams');
|
||||
const tasksDir = path.join(tempDir, 'tasks');
|
||||
const projectDir = path.join(projectsDir, 'encoded-project');
|
||||
const sessionPath = path.join(projectDir, 'session-1.jsonl');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.mkdirSync(todosDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true });
|
||||
fs.writeFileSync(sessionPath, jsonlLine('a1', 'baseline'), 'utf8');
|
||||
useRealAccess();
|
||||
|
||||
const projectWatcher = createFakeWatcher();
|
||||
const todoWatcher = createFakeWatcher();
|
||||
const watchMock = vi.mocked(fs.watch);
|
||||
watchMock.mockImplementation((targetPath) => {
|
||||
if (String(targetPath) === projectsDir) return projectWatcher;
|
||||
if (String(targetPath) === todosDir) return todoWatcher;
|
||||
throw new Error(`Unexpected watch path: ${String(targetPath)}`);
|
||||
});
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('file-change', (event) => events.push(event));
|
||||
watcher.start();
|
||||
|
||||
await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2));
|
||||
(projectWatcher as unknown as EventEmitter).emit(
|
||||
'error',
|
||||
Object.assign(new Error('too many open files'), { code: 'EMFILE' })
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const projectsSource = getChangeSource(watcher, 'projects');
|
||||
expect(projectsSource.currentPollingTimer).not.toBeNull();
|
||||
expect(getRetryTimer(watcher)).toBeNull();
|
||||
expect(projectWatcher.close).toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(projectsSource.isPollingPrimed).toBe(true));
|
||||
|
||||
fs.appendFileSync(sessionPath, jsonlLine('a2', 'changed'), 'utf8');
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual({
|
||||
type: 'change',
|
||||
path: sessionPath,
|
||||
projectId: 'encoded-project',
|
||||
sessionId: 'session-1',
|
||||
isSubagent: false,
|
||||
})
|
||||
);
|
||||
|
||||
const subagentsDir = path.join(projectDir, 'session-1', 'subagents');
|
||||
const subagentPath = path.join(subagentsDir, 'agent-worker.jsonl');
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
fs.writeFileSync(subagentPath, jsonlLine('s1', 'subagent'), 'utf8');
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual({
|
||||
type: 'add',
|
||||
path: subagentPath,
|
||||
projectId: 'encoded-project',
|
||||
sessionId: 'session-1',
|
||||
isSubagent: true,
|
||||
})
|
||||
);
|
||||
|
||||
watcher.stop();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ignores stale native project callbacks after EMFILE fallback switches to polling', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-project-stale-fallback-'));
|
||||
setClaudeBasePathOverride(tempDir);
|
||||
const projectsDir = path.join(tempDir, 'projects');
|
||||
const todosDir = path.join(tempDir, 'todos');
|
||||
const teamsDir = path.join(tempDir, 'teams');
|
||||
const tasksDir = path.join(tempDir, 'tasks');
|
||||
const projectDir = path.join(projectsDir, 'encoded-project');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.mkdirSync(todosDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectDir, 'stale-session.jsonl'), jsonlLine('stale', 'stale'), 'utf8');
|
||||
useRealAccess();
|
||||
|
||||
const projectWatcher = createFakeWatcher();
|
||||
const todoWatcher = createFakeWatcher();
|
||||
const projectCallbacks: Array<(eventType: string, filename: string) => void> = [];
|
||||
mockFsWatchImplementation((targetPath, optionsOrListener, maybeListener) => {
|
||||
const listener = getNativeWatchCallback(optionsOrListener, maybeListener);
|
||||
if (String(targetPath) === projectsDir) {
|
||||
if (listener) {
|
||||
projectCallbacks.push(listener);
|
||||
}
|
||||
return projectWatcher;
|
||||
}
|
||||
if (String(targetPath) === todosDir) {
|
||||
return todoWatcher;
|
||||
}
|
||||
throw new Error(`Unexpected watch path: ${String(targetPath)}`);
|
||||
});
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('file-change', (event) => events.push(event));
|
||||
watcher.start();
|
||||
|
||||
await vi.waitFor(() => expect(projectCallbacks).toHaveLength(1));
|
||||
(projectWatcher as unknown as EventEmitter).emit(
|
||||
'error',
|
||||
Object.assign(new Error('too many open files'), { code: 'EMFILE' })
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const projectsSource = getChangeSource(watcher, 'projects');
|
||||
expect(projectsSource.currentPollingTimer).not.toBeNull();
|
||||
await vi.waitFor(() => expect(projectsSource.isPollingPrimed).toBe(true));
|
||||
|
||||
projectCallbacks[0]('rename', 'encoded-project/stale-session.jsonl');
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(events).toEqual([]);
|
||||
|
||||
watcher.stop();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('falls back to todos polling on EMFILE and still emits todo changes', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-todo-emfile-'));
|
||||
setClaudeBasePathOverride(tempDir);
|
||||
const projectsDir = path.join(tempDir, 'projects');
|
||||
const todosDir = path.join(tempDir, 'todos');
|
||||
const teamsDir = path.join(tempDir, 'teams');
|
||||
const tasksDir = path.join(tempDir, 'tasks');
|
||||
const todoPath = path.join(todosDir, 'session-1.json');
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
fs.mkdirSync(todosDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true });
|
||||
fs.writeFileSync(todoPath, '{"items":[]}', 'utf8');
|
||||
useRealAccess();
|
||||
|
||||
const projectWatcher = createFakeWatcher();
|
||||
const todoWatcher = createFakeWatcher();
|
||||
const watchMock = vi.mocked(fs.watch);
|
||||
watchMock.mockImplementation((targetPath) => {
|
||||
if (String(targetPath) === projectsDir) return projectWatcher;
|
||||
if (String(targetPath) === todosDir) return todoWatcher;
|
||||
throw new Error(`Unexpected watch path: ${String(targetPath)}`);
|
||||
});
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('todo-change', (event) => events.push(event));
|
||||
watcher.start();
|
||||
|
||||
await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2));
|
||||
(todoWatcher as unknown as EventEmitter).emit(
|
||||
'error',
|
||||
Object.assign(new Error('too many open files'), { code: 'EMFILE' })
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const todosSource = getChangeSource(watcher, 'todos');
|
||||
expect(todosSource.currentPollingTimer).not.toBeNull();
|
||||
expect(getRetryTimer(watcher)).toBeNull();
|
||||
expect(todoWatcher.close).toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(todosSource.isPollingPrimed).toBe(true));
|
||||
|
||||
fs.writeFileSync(todoPath, '{"items":[{"text":"done"}]}', 'utf8');
|
||||
await todosSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual({
|
||||
type: 'change',
|
||||
path: todoPath,
|
||||
sessionId: 'session-1',
|
||||
isSubagent: false,
|
||||
})
|
||||
);
|
||||
|
||||
watcher.stop();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses a custom local provider for project polling snapshots', async () => {
|
||||
const projectsDir = '/virtual/projects';
|
||||
const todosDir = '/virtual/todos';
|
||||
const sessionEntry = createFsDirent('session-1.jsonl', 'file', { size: 10, mtimeMs: 1000 });
|
||||
const fsProvider = {
|
||||
type: 'local' as const,
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
size: 10,
|
||||
mtimeMs: 1000,
|
||||
birthtimeMs: 1000,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
}),
|
||||
readdir: vi.fn(async (dirPath: string) => {
|
||||
if (dirPath === projectsDir) {
|
||||
return [createFsDirent('encoded-project', 'directory')];
|
||||
}
|
||||
if (dirPath === path.join(projectsDir, 'encoded-project')) {
|
||||
return [sessionEntry];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
createReadStream: vi.fn(() => Readable.from([])),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('file-change', (event) => events.push(event));
|
||||
|
||||
setWatcherActive(watcher);
|
||||
const projectsSource = getChangeSource(watcher, 'projects');
|
||||
await projectsSource.pollOnce();
|
||||
expect(events).toEqual([]);
|
||||
|
||||
sessionEntry.size = 12;
|
||||
sessionEntry.mtimeMs = 2000;
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(fsProvider.readdir).toHaveBeenCalledWith(projectsDir);
|
||||
expect(events).toContainEqual({
|
||||
type: 'change',
|
||||
path: path.join(projectsDir, 'encoded-project', 'session-1.jsonl'),
|
||||
projectId: 'encoded-project',
|
||||
sessionId: 'session-1',
|
||||
isSubagent: false,
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('treats SSH not-found subagent directories as empty during project polling', async () => {
|
||||
const projectsDir = '/remote/projects';
|
||||
const todosDir = '/remote/todos';
|
||||
const projectDir = path.join(projectsDir, 'encoded-project');
|
||||
const sessionPath = path.join(projectDir, 'session-1.jsonl');
|
||||
let size = 10;
|
||||
let mtimeMs = 1000;
|
||||
const fsProvider = {
|
||||
type: 'ssh' as const,
|
||||
exists: vi.fn(async (filePath: string) => filePath === sessionPath),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
stat: vi.fn(async (filePath: string) => {
|
||||
if (filePath !== sessionPath) {
|
||||
throw Object.assign(new Error('not found'), { code: '2' });
|
||||
}
|
||||
return {
|
||||
size,
|
||||
mtimeMs,
|
||||
birthtimeMs: 1000,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
};
|
||||
}),
|
||||
readdir: vi.fn(async (dirPath: string) => {
|
||||
if (dirPath === projectsDir) {
|
||||
return [createFsDirent('encoded-project', 'directory')];
|
||||
}
|
||||
if (dirPath === projectDir) {
|
||||
return [
|
||||
createFsDirent('session-1.jsonl', 'file'),
|
||||
createFsDirent('session-1', 'directory'),
|
||||
];
|
||||
}
|
||||
if (dirPath === path.join(projectDir, 'session-1', 'subagents')) {
|
||||
throw Object.assign(new Error('not found'), { code: '2' });
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
createReadStream: vi.fn(() => Readable.from([])),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('file-change', (event) => events.push(event));
|
||||
|
||||
setWatcherActive(watcher);
|
||||
const projectsSource = getChangeSource(watcher, 'projects');
|
||||
await projectsSource.pollOnce();
|
||||
expect(events).toEqual([]);
|
||||
|
||||
size = 12;
|
||||
mtimeMs = 2000;
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'change',
|
||||
path: sessionPath,
|
||||
projectId: 'encoded-project',
|
||||
sessionId: 'session-1',
|
||||
isSubagent: false,
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it.each(['EMFILE', 'ENOENT'])(
|
||||
'does not emit false project deletes when a polling stat fails with %s',
|
||||
async (failureCode) => {
|
||||
const projectsDir = '/virtual/projects';
|
||||
const todosDir = '/virtual/todos';
|
||||
const sessionPath = path.join(projectsDir, 'encoded-project', 'session-1.jsonl');
|
||||
let statShouldFail = false;
|
||||
let size = 10;
|
||||
let mtimeMs = 1000;
|
||||
const fsProvider = {
|
||||
type: 'local' as const,
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
stat: vi.fn(async () => {
|
||||
if (statShouldFail) {
|
||||
throw Object.assign(new Error(failureCode), { code: failureCode });
|
||||
}
|
||||
return {
|
||||
size,
|
||||
mtimeMs,
|
||||
birthtimeMs: 1000,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
};
|
||||
}),
|
||||
readdir: vi.fn(async (dirPath: string) => {
|
||||
if (dirPath === projectsDir) {
|
||||
return [createFsDirent('encoded-project', 'directory')];
|
||||
}
|
||||
if (dirPath === path.join(projectsDir, 'encoded-project')) {
|
||||
return [createFsDirent('session-1.jsonl', 'file')];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
createReadStream: vi.fn(() => Readable.from([])),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('file-change', (event) => events.push(event));
|
||||
|
||||
setWatcherActive(watcher);
|
||||
const projectsSource = getChangeSource(watcher, 'projects');
|
||||
await projectsSource.pollOnce();
|
||||
expect(events).toEqual([]);
|
||||
|
||||
statShouldFail = true;
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(events).toEqual([]);
|
||||
|
||||
statShouldFail = false;
|
||||
size = 12;
|
||||
mtimeMs = 2000;
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'change',
|
||||
path: sessionPath,
|
||||
projectId: 'encoded-project',
|
||||
sessionId: 'session-1',
|
||||
isSubagent: false,
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
}
|
||||
);
|
||||
|
||||
it('does not emit false project deletes when the polling root is temporarily missing', async () => {
|
||||
const projectsDir = '/virtual/projects';
|
||||
const todosDir = '/virtual/todos';
|
||||
const sessionPath = path.join(projectsDir, 'encoded-project', 'session-1.jsonl');
|
||||
let rootMissing = false;
|
||||
let size = 10;
|
||||
let mtimeMs = 1000;
|
||||
const fsProvider = {
|
||||
type: 'local' as const,
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
stat: vi.fn().mockImplementation(async () => ({
|
||||
size,
|
||||
mtimeMs,
|
||||
birthtimeMs: 1000,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
})),
|
||||
readdir: vi.fn(async (dirPath: string) => {
|
||||
if (dirPath === projectsDir) {
|
||||
if (rootMissing) {
|
||||
throw Object.assign(new Error('missing root'), { code: 'ENOENT' });
|
||||
}
|
||||
return [createFsDirent('encoded-project', 'directory')];
|
||||
}
|
||||
if (dirPath === path.join(projectsDir, 'encoded-project')) {
|
||||
return [createFsDirent('session-1.jsonl', 'file')];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
createReadStream: vi.fn(() => Readable.from([])),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('file-change', (event) => events.push(event));
|
||||
|
||||
setWatcherActive(watcher);
|
||||
const projectsSource = getChangeSource(watcher, 'projects');
|
||||
await projectsSource.pollOnce();
|
||||
expect(events).toEqual([]);
|
||||
|
||||
rootMissing = true;
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(events).toEqual([]);
|
||||
|
||||
rootMissing = false;
|
||||
size = 12;
|
||||
mtimeMs = 2000;
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'change',
|
||||
path: sessionPath,
|
||||
projectId: 'encoded-project',
|
||||
sessionId: 'session-1',
|
||||
isSubagent: false,
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('still emits project deletes when polling no longer lists a file', async () => {
|
||||
const projectsDir = '/virtual/projects';
|
||||
const todosDir = '/virtual/todos';
|
||||
const sessionPath = path.join(projectsDir, 'encoded-project', 'session-1.jsonl');
|
||||
let filePresent = true;
|
||||
const fsProvider = {
|
||||
type: 'local' as const,
|
||||
exists: vi.fn(async (filePath: string) => filePath !== sessionPath || filePresent),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
size: 10,
|
||||
mtimeMs: 1000,
|
||||
birthtimeMs: 1000,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
}),
|
||||
readdir: vi.fn(async (dirPath: string) => {
|
||||
if (dirPath === projectsDir) {
|
||||
return [createFsDirent('encoded-project', 'directory')];
|
||||
}
|
||||
if (dirPath === path.join(projectsDir, 'encoded-project')) {
|
||||
return filePresent ? [createFsDirent('session-1.jsonl', 'file')] : [];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
createReadStream: vi.fn(() => Readable.from([])),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('file-change', (event) => events.push(event));
|
||||
|
||||
setWatcherActive(watcher);
|
||||
const projectsSource = getChangeSource(watcher, 'projects');
|
||||
await projectsSource.pollOnce();
|
||||
expect(events).toEqual([]);
|
||||
|
||||
filePresent = false;
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'unlink',
|
||||
path: sessionPath,
|
||||
projectId: 'encoded-project',
|
||||
sessionId: 'session-1',
|
||||
isSubagent: false,
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('does not emit false project deletes when a listed project dir vanishes during polling', async () => {
|
||||
const projectsDir = '/virtual/projects';
|
||||
const todosDir = '/virtual/todos';
|
||||
const projectDir = path.join(projectsDir, 'encoded-project');
|
||||
const sessionPath = path.join(projectDir, 'session-1.jsonl');
|
||||
let rootListsProject = true;
|
||||
let projectDirMissing = false;
|
||||
const fsProvider = {
|
||||
type: 'local' as const,
|
||||
exists: vi.fn(async (filePath: string) => filePath !== sessionPath || rootListsProject),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
stat: vi.fn().mockResolvedValue({
|
||||
size: 10,
|
||||
mtimeMs: 1000,
|
||||
birthtimeMs: 1000,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
}),
|
||||
readdir: vi.fn(async (dirPath: string) => {
|
||||
if (dirPath === projectsDir) {
|
||||
return rootListsProject ? [createFsDirent('encoded-project', 'directory')] : [];
|
||||
}
|
||||
if (dirPath === projectDir) {
|
||||
if (projectDirMissing) {
|
||||
throw Object.assign(new Error('missing project dir'), { code: 'ENOENT' });
|
||||
}
|
||||
return [createFsDirent('session-1.jsonl', 'file')];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
createReadStream: vi.fn(() => Readable.from([])),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('file-change', (event) => events.push(event));
|
||||
|
||||
setWatcherActive(watcher);
|
||||
const projectsSource = getChangeSource(watcher, 'projects');
|
||||
await projectsSource.pollOnce();
|
||||
expect(events).toEqual([]);
|
||||
|
||||
projectDirMissing = true;
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(events).toEqual([]);
|
||||
|
||||
rootListsProject = false;
|
||||
await projectsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'unlink',
|
||||
path: sessionPath,
|
||||
projectId: 'encoded-project',
|
||||
sessionId: 'session-1',
|
||||
isSubagent: false,
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('falls back to teams polling when the chokidar registry hits the file descriptor limit', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-emfile-'));
|
||||
setClaudeBasePathOverride(tempDir);
|
||||
|
|
@ -303,14 +1011,10 @@ describe('FileWatcher', () => {
|
|||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const watcherAny = watcher as unknown as {
|
||||
retryTimer: NodeJS.Timeout | null;
|
||||
teamsPollingTimer: NodeJS.Timeout | null;
|
||||
teamsPollingPrimed: boolean;
|
||||
};
|
||||
expect(watcherAny.teamsPollingTimer).not.toBeNull();
|
||||
expect(watcherAny.retryTimer).toBeNull();
|
||||
await vi.waitFor(() => expect(watcherAny.teamsPollingPrimed).toBe(true));
|
||||
const teamsSource = getChangeSource(watcher, 'teams');
|
||||
expect(teamsSource.currentPollingTimer).not.toBeNull();
|
||||
expect(getRetryTimer(watcher)).toBeNull();
|
||||
await vi.waitFor(() => expect(teamsSource.isPollingPrimed).toBe(true));
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(events).toEqual([]);
|
||||
|
||||
|
|
@ -350,12 +1054,8 @@ describe('FileWatcher', () => {
|
|||
tasksWatcher.emit('error', Object.assign(new Error(code), { code }));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const watcherAny = watcher as unknown as {
|
||||
retryTimer: NodeJS.Timeout | null;
|
||||
tasksPollingTimer: NodeJS.Timeout | null;
|
||||
};
|
||||
expect(watcherAny.tasksPollingTimer).not.toBeNull();
|
||||
expect(watcherAny.retryTimer).toBeNull();
|
||||
expect(getChangeSource(watcher, 'tasks').currentPollingTimer).not.toBeNull();
|
||||
expect(getRetryTimer(watcher)).toBeNull();
|
||||
expect(tasksWatcher.close).toHaveBeenCalled();
|
||||
|
||||
watcher.stop();
|
||||
|
|
@ -391,12 +1091,8 @@ describe('FileWatcher', () => {
|
|||
watcher.start();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const watcherAny = watcher as unknown as {
|
||||
retryTimer: NodeJS.Timeout | null;
|
||||
teamsPollingTimer: NodeJS.Timeout | null;
|
||||
};
|
||||
expect(watcherAny.teamsPollingTimer).not.toBeNull();
|
||||
expect(watcherAny.retryTimer).toBeNull();
|
||||
expect(getChangeSource(watcher, 'teams').currentPollingTimer).not.toBeNull();
|
||||
expect(getRetryTimer(watcher)).toBeNull();
|
||||
});
|
||||
|
||||
expect(chokidarMock.watch).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -406,6 +1102,49 @@ describe('FileWatcher', () => {
|
|||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('closes a partially-created teams registry when initial start fails', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-partial-limit-'));
|
||||
setClaudeBasePathOverride(tempDir);
|
||||
const projectsDir = path.join(tempDir, 'projects');
|
||||
const todosDir = path.join(tempDir, 'todos');
|
||||
const teamsDir = path.join(tempDir, 'teams');
|
||||
const tasksDir = path.join(tempDir, 'tasks');
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
fs.mkdirSync(todosDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true });
|
||||
useRealAccess();
|
||||
|
||||
const watchMock = vi.mocked(fs.watch);
|
||||
watchMock.mockImplementation(() => createFakeWatcher());
|
||||
const partialWatcher = chokidarMock.createWatcher([path.normalize(teamsDir)], {});
|
||||
partialWatcher.on = vi.fn(() => {
|
||||
throw Object.assign(new Error('watch limit during listener registration'), {
|
||||
code: 'EMFILE',
|
||||
});
|
||||
});
|
||||
chokidarMock.instances.length = 0;
|
||||
chokidarMock.watch.mockImplementation((targets, options) => {
|
||||
const targetList = Array.isArray(targets) ? targets : [targets];
|
||||
if (targetList.includes(path.normalize(teamsDir))) {
|
||||
return partialWatcher;
|
||||
}
|
||||
return chokidarMock.createWatcher(targets, options);
|
||||
});
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir);
|
||||
watcher.start();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getChangeSource(watcher, 'teams').currentPollingTimer).not.toBeNull();
|
||||
expect(partialWatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('retries chokidar registry after a non-limit error without enabling polling', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-nonlimit-'));
|
||||
setClaudeBasePathOverride(tempDir);
|
||||
|
|
@ -432,12 +1171,8 @@ describe('FileWatcher', () => {
|
|||
teamsWatcher.emit('error', Object.assign(new Error('permission denied'), { code: 'EACCES' }));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const watcherAny = watcher as unknown as {
|
||||
retryTimer: NodeJS.Timeout | null;
|
||||
teamsPollingTimer: NodeJS.Timeout | null;
|
||||
};
|
||||
expect(watcherAny.teamsPollingTimer).toBeNull();
|
||||
expect(watcherAny.retryTimer).not.toBeNull();
|
||||
expect(getChangeSource(watcher, 'teams').currentPollingTimer).toBeNull();
|
||||
expect(getRetryTimer(watcher)).not.toBeNull();
|
||||
expect(teamsWatcher.close).toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
|
@ -472,12 +1207,8 @@ describe('FileWatcher', () => {
|
|||
await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2));
|
||||
|
||||
const watcherAny = watcher as unknown as {
|
||||
teamsPollingTimer: NodeJS.Timeout | null;
|
||||
tasksPollingTimer: NodeJS.Timeout | null;
|
||||
};
|
||||
expect(watcherAny.teamsPollingTimer).toBeNull();
|
||||
expect(watcherAny.tasksPollingTimer).toBeNull();
|
||||
expect(getChangeSource(watcher, 'teams').currentPollingTimer).toBeNull();
|
||||
expect(getChangeSource(watcher, 'tasks').currentPollingTimer).toBeNull();
|
||||
expect(watchMock).not.toHaveBeenCalledWith(teamsDir, expect.anything(), expect.anything());
|
||||
expect(watchMock).not.toHaveBeenCalledWith(tasksDir, expect.anything(), expect.anything());
|
||||
expectChokidarOptions(getChokidarWatcherForRoot(teamsDir));
|
||||
|
|
@ -513,6 +1244,7 @@ describe('FileWatcher', () => {
|
|||
|
||||
const teamsWatcher = getChokidarWatcherForRoot(teamsDir);
|
||||
teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'config.json'));
|
||||
teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'kanban-state.json'));
|
||||
teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'inboxes', 'user.json'));
|
||||
teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'sentMessages.json'));
|
||||
teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'processes.json'));
|
||||
|
|
@ -524,6 +1256,7 @@ describe('FileWatcher', () => {
|
|||
|
||||
expect(events).toEqual([
|
||||
{ type: 'config', teamName: 'base-1', detail: 'config.json' },
|
||||
{ type: 'config', teamName: 'base-1', detail: 'kanban-state.json' },
|
||||
{ type: 'inbox', teamName: 'base-1', detail: 'inboxes/user.json' },
|
||||
{ type: 'inbox', teamName: 'base-1', detail: 'sentMessages.json' },
|
||||
{ type: 'process', teamName: 'base-1', detail: 'processes.json' },
|
||||
|
|
@ -782,6 +1515,49 @@ describe('FileWatcher', () => {
|
|||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps rebuilding the registry when the previous chokidar close throws synchronously', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-close-throw-'));
|
||||
setClaudeBasePathOverride(tempDir);
|
||||
const projectsDir = path.join(tempDir, 'projects');
|
||||
const todosDir = path.join(tempDir, 'todos');
|
||||
const teamsDir = path.join(tempDir, 'teams');
|
||||
const tasksDir = path.join(tempDir, 'tasks');
|
||||
const addedTeamDir = path.join(teamsDir, 'base-2');
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
fs.mkdirSync(todosDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(teamsDir, 'base-1', 'inboxes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tasksDir, 'base-1'), { recursive: true });
|
||||
useRealAccess();
|
||||
|
||||
const watchMock = vi.mocked(fs.watch);
|
||||
watchMock.mockImplementation(() => createFakeWatcher());
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, todosDir);
|
||||
const events: unknown[] = [];
|
||||
watcher.on('team-change', (event) => events.push(event));
|
||||
watcher.start();
|
||||
|
||||
await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2));
|
||||
const teamsWatcher = getChokidarWatcherForRoot(teamsDir);
|
||||
teamsWatcher.close.mockImplementationOnce(() => {
|
||||
throw new Error('close failed');
|
||||
});
|
||||
|
||||
fs.mkdirSync(addedTeamDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(addedTeamDir, 'config.json'), '{}', 'utf8');
|
||||
teamsWatcher.emit('addDir', addedTeamDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(chokidarMock.watch).toHaveBeenCalledTimes(3);
|
||||
expect(getChokidarWatcherForRoot(teamsDir).targets).toContain(path.normalize(addedTeamDir));
|
||||
expect(events).toEqual([{ type: 'config', teamName: 'base-2', detail: 'config.json' }]);
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ignores events from an old chokidar generation after registry rebuild', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-generation-'));
|
||||
setClaudeBasePathOverride(tempDir);
|
||||
|
|
@ -1048,16 +1824,13 @@ describe('FileWatcher', () => {
|
|||
const events: unknown[] = [];
|
||||
watcher.on('team-change', (event) => events.push(event));
|
||||
|
||||
const watcherAny = watcher as unknown as {
|
||||
isWatching: boolean;
|
||||
pollTeamsForChanges: () => Promise<void>;
|
||||
};
|
||||
watcherAny.isWatching = true;
|
||||
await watcherAny.pollTeamsForChanges();
|
||||
setWatcherActive(watcher);
|
||||
const teamsSource = getChangeSource(watcher, 'teams');
|
||||
await teamsSource.pollOnce();
|
||||
expect(events).toEqual([]);
|
||||
|
||||
fs.writeFileSync(inboxPath, '[{"messageId":"m1"}]', 'utf8');
|
||||
await watcherAny.pollTeamsForChanges();
|
||||
await teamsSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(events).toEqual([{ type: 'inbox', teamName: 'base-1', detail: 'inboxes/user.json' }]);
|
||||
|
|
@ -1083,16 +1856,13 @@ describe('FileWatcher', () => {
|
|||
const events: unknown[] = [];
|
||||
watcher.on('team-change', (event) => events.push(event));
|
||||
|
||||
const watcherAny = watcher as unknown as {
|
||||
isWatching: boolean;
|
||||
pollTasksForChanges: () => Promise<void>;
|
||||
};
|
||||
watcherAny.isWatching = true;
|
||||
await watcherAny.pollTasksForChanges();
|
||||
setWatcherActive(watcher);
|
||||
const tasksSource = getChangeSource(watcher, 'tasks');
|
||||
await tasksSource.pollOnce();
|
||||
expect(events).toEqual([]);
|
||||
|
||||
fs.writeFileSync(taskPath, '{"status":"running"}', 'utf8');
|
||||
await watcherAny.pollTasksForChanges();
|
||||
await tasksSource.pollOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(events).toEqual([
|
||||
|
|
|
|||
|
|
@ -243,6 +243,36 @@ describe('OpenCodeRuntimeInstallerService resolver', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('resolves from fast fallback PATH without spawning shell env when shell env is disabled', async () => {
|
||||
const binaryPath = path.join(tempRoot!, 'merged-cli-path', 'bin', 'opencode');
|
||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||
buildMergedCliPathMock.mockReturnValue(path.dirname(binaryPath));
|
||||
|
||||
await expect(
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath({ includeShellEnv: false })
|
||||
).resolves.toBe(binaryPath);
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||
expect(execCliMock).toHaveBeenCalledWith(binaryPath, ['--version'], {
|
||||
timeout: 10_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not spawn shell env for shell-only PATH installs when shell env is disabled', async () => {
|
||||
const binaryPath = path.join(tempRoot!, 'custom-npm-prefix', 'bin', 'opencode');
|
||||
await mkdir(path.dirname(binaryPath), { recursive: true });
|
||||
await writeFile(binaryPath, 'binary', { mode: 0o755 });
|
||||
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
|
||||
PATH: path.dirname(binaryPath),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath({ includeShellEnv: false })
|
||||
).resolves.toBeNull();
|
||||
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a verified OpenCode binary from nvm when desktop PATH misses npm globals', async () => {
|
||||
const olderBinaryPath = path.join(
|
||||
tempRoot!,
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
|
|||
[
|
||||
'#!/bin/sh',
|
||||
'if [ "$1" = "-e" ]; then',
|
||||
' printf "%s" "$FAKE_NODE_PATH"',
|
||||
' printf "{\\"execPath\\":\\"%s\\",\\"version\\":\\"%s\\"}" "$FAKE_NODE_PATH" "22.0.0"',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'echo "unexpected node args: $*" >&2',
|
||||
|
|
|
|||
|
|
@ -7,9 +7,8 @@ import type { PathLike } from 'fs';
|
|||
const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
|
||||
const mockGetShellPreferredHome = vi.fn<() => string>();
|
||||
const mockGetClaudeBasePath = vi.fn<() => string>();
|
||||
const mockResolveInteractiveShellEnvBestEffort = vi.fn<
|
||||
(options?: unknown) => Promise<NodeJS.ProcessEnv>
|
||||
>();
|
||||
const mockResolveInteractiveShellEnvBestEffort =
|
||||
vi.fn<(options?: unknown) => Promise<NodeJS.ProcessEnv>>();
|
||||
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'agent_teams_orchestrator'>();
|
||||
const mockGetDoctorInvokedCandidates = vi.fn<(commandName: string) => Promise<string[]>>();
|
||||
|
||||
|
|
@ -120,6 +119,7 @@ describe('ClaudeBinaryResolver', () => {
|
|||
|
||||
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
|
||||
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
||||
expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers the dedicated CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH override', async () => {
|
||||
|
|
@ -138,6 +138,7 @@ describe('ClaudeBinaryResolver', () => {
|
|||
|
||||
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
|
||||
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
||||
expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not wait for shell env before using an explicit absolute runtime override', async () => {
|
||||
|
|
@ -249,6 +250,7 @@ describe('ClaudeBinaryResolver', () => {
|
|||
|
||||
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
|
||||
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
|
||||
expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finds npm-local Claude install in the vendor bin directory', async () => {
|
||||
|
|
|
|||
|
|
@ -146,6 +146,80 @@ describe('TeamLaunchFailureArtifactPack', () => {
|
|||
expect(launchStateContent).not.toContain('sk-ant-');
|
||||
});
|
||||
|
||||
it('copies runtime process logs into the launch failure artifact pack', async () => {
|
||||
const teamName = 'runtime-artifact-team';
|
||||
const runId = 'run-readiness-timeout';
|
||||
const teamDir = path.join(getTeamsBasePath(), teamName);
|
||||
const runtimeDir = path.join(teamDir, 'runtime');
|
||||
await fs.mkdir(runtimeDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(runtimeDir, 'alice.runtime.jsonl'),
|
||||
'{"type":"runtime_ready","token":"abcdefghijklmnopqrstuvwxyz123456"}\n',
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(runtimeDir, 'alice.stdout.log'),
|
||||
'stdout OPENAI_API_KEY=sk-proj-cccccccccccccccccccccccccccccccccccccccc\n',
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(runtimeDir, 'alice.stderr.log'),
|
||||
'stderr Teammate process alice did not become inbox_poller_ready: timed out waiting for inbox_poller_ready\n',
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(path.join(runtimeDir, 'ignored.txt'), 'ignore me\n', 'utf8');
|
||||
|
||||
const result = await writeTeamLaunchFailureArtifactPack({
|
||||
teamName,
|
||||
runId,
|
||||
reason: 'launch_progress_failed',
|
||||
memberSpawnStatuses: {
|
||||
alice: {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailureReason:
|
||||
'Teammate process alice did not become inbox_poller_ready: timed out waiting for inbox_poller_ready',
|
||||
updatedAt: '2026-05-09T00:01:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = JSON.parse(await fs.readFile(result.manifestPath, 'utf8')) as {
|
||||
artifactFiles: string[];
|
||||
classification: { code: string };
|
||||
};
|
||||
expect(manifest.classification.code).toBe('process_readiness_timeout');
|
||||
expect(manifest.artifactFiles).toContain('runtime/alice.runtime.jsonl');
|
||||
expect(manifest.artifactFiles).toContain('runtime/alice.stdout.log');
|
||||
expect(manifest.artifactFiles).toContain('runtime/alice.stderr.log');
|
||||
expect(manifest.artifactFiles).not.toContain('runtime/ignored.txt');
|
||||
|
||||
const copiedStdout = await fs.readFile(
|
||||
path.join(result.directory, 'runtime', 'alice.stdout.log'),
|
||||
'utf8'
|
||||
);
|
||||
expect(copiedStdout).toContain('OPENAI_API_KEY=[REDACTED]');
|
||||
expect(copiedStdout).not.toContain('sk-proj-');
|
||||
|
||||
const copiedEvents = await fs.readFile(
|
||||
path.join(result.directory, 'runtime', 'alice.runtime.jsonl'),
|
||||
'utf8'
|
||||
);
|
||||
expect(copiedEvents).toContain('"token":"[REDACTED]"');
|
||||
expect(copiedEvents).not.toContain('abcdefghijklmnopqrstuvwxyz123456');
|
||||
|
||||
const bundle = await readTeamLaunchFailureDiagnosticsBundle(teamName, runId);
|
||||
const labels = bundle.files.map((file) => file.label);
|
||||
expect(labels).toContain('launch-failure-artifacts/runtime/alice.runtime.jsonl');
|
||||
expect(labels).toContain('launch-failure-artifacts/runtime/alice.stdout.log');
|
||||
expect(labels).toContain('launch-failure-artifacts/runtime/alice.stderr.log');
|
||||
expect(
|
||||
bundle.files.find(
|
||||
(file) => file.label === 'launch-failure-artifacts/runtime/alice.stdout.log'
|
||||
)?.content
|
||||
).toContain('OPENAI_API_KEY=[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts common bearer and token-shaped secrets', () => {
|
||||
const redacted = redactLaunchFailureArtifactText(
|
||||
'Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456 token: abcdefghijklmnopqrstuvwxyz123456 ANTHROPIC_AUTH_TOKEN=lmstudio CODEX_API_KEY="quoted-codex-token" OPENROUTER_API_KEY=\'quoted-router-token\''
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@ const hoisted = vi.hoisted(() => ({
|
|||
isPackaged: false,
|
||||
version: '9.9.9-test',
|
||||
},
|
||||
execCliMock: vi.fn<ExecCliMock>(async () => ({ stdout: '/mock/node', stderr: '' })),
|
||||
execCliMock: vi.fn<ExecCliMock>(async () => ({
|
||||
stdout: JSON.stringify({ execPath: '/mock/node', version: '20.11.0' }),
|
||||
stderr: '',
|
||||
})),
|
||||
cachedShellEnv: null as NodeJS.ProcessEnv | null,
|
||||
resolveInteractiveShellEnvMock: vi.fn<ResolveInteractiveShellEnvMock>(
|
||||
async () => ({} as NodeJS.ProcessEnv)
|
||||
async () => ({}) as NodeJS.ProcessEnv
|
||||
),
|
||||
}));
|
||||
|
||||
|
|
@ -65,6 +68,10 @@ import {
|
|||
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
|
||||
function nodeRuntimeProbeStdout(execPath: string, version = '20.11.0'): string {
|
||||
return JSON.stringify({ execPath, version });
|
||||
}
|
||||
|
||||
describe('TeamMcpConfigBuilder', () => {
|
||||
const createdPaths: string[] = [];
|
||||
const createdDirs: string[] = [];
|
||||
|
|
@ -95,7 +102,9 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
|
||||
function readGeneratedServer(
|
||||
configPath: string
|
||||
): { command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> } | undefined {
|
||||
):
|
||||
| { command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> }
|
||||
| undefined {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as {
|
||||
mcpServers?: Record<
|
||||
|
|
@ -206,7 +215,10 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
setPackagedMode(false);
|
||||
setResourcesPath(undefined);
|
||||
hoisted.execCliMock.mockClear();
|
||||
hoisted.execCliMock.mockResolvedValue({ stdout: '/mock/node', stderr: '' });
|
||||
hoisted.execCliMock.mockResolvedValue({
|
||||
stdout: nodeRuntimeProbeStdout('/mock/node'),
|
||||
stderr: '',
|
||||
});
|
||||
hoisted.cachedShellEnv = null;
|
||||
hoisted.resolveInteractiveShellEnvMock.mockClear();
|
||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({});
|
||||
|
|
@ -318,7 +330,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
expect(readGeneratedServer(configPath)?.command).toBe('/mock/node');
|
||||
expect(hoisted.execCliMock).toHaveBeenCalledWith(
|
||||
'node',
|
||||
['-e', 'process.stdout.write(process.execPath)'],
|
||||
['-e', expect.stringContaining('process.versions.node')],
|
||||
expect.objectContaining({
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
|
|
@ -346,7 +358,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
expect(command).toBe('node');
|
||||
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
||||
expect(env?.PATH?.split(path.delimiter)[0]).toBe('/mock-shell-node-bin');
|
||||
return { stdout: '/mock-shell-node-bin/node', stderr: '' };
|
||||
return { stdout: nodeRuntimeProbeStdout('/mock-shell-node-bin/node'), stderr: '' };
|
||||
});
|
||||
|
||||
const builder = new TeamMcpConfigBuilder();
|
||||
|
|
@ -361,64 +373,64 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
it.each(['linux', 'darwin', 'win32'] as const)(
|
||||
'uses the packaged Electron Node runtime for %s packaged MCP launches',
|
||||
async (platform) => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath');
|
||||
const electronBinary =
|
||||
platform === 'win32'
|
||||
? 'C:\\Program Files\\Agent Teams AI\\agent-teams-ai.exe'
|
||||
: '/opt/Agent Teams AI/agent-teams-ai';
|
||||
setPackagedMode(true, '3.0.0');
|
||||
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||
createdDirs.push(resourcesDir);
|
||||
createPackagedServerBundle(resourcesDir, '// packaged server');
|
||||
setResourcesPath(resourcesDir);
|
||||
hoisted.execCliMock.mockResolvedValue({
|
||||
stdout: 'agent-teams-electron-node-ok',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: electronBinary,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const launchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
const builder = new TeamMcpConfigBuilder();
|
||||
const configPath = await builder.writeConfigFile();
|
||||
createdPaths.push(configPath);
|
||||
const server = readGeneratedServer(configPath);
|
||||
const expectedEntry = path.join(tempAppData, 'mcp-server', '3.0.0', 'index.js');
|
||||
|
||||
expect(launchSpec).toEqual({
|
||||
command: electronBinary,
|
||||
args: [expectedEntry],
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath');
|
||||
const electronBinary =
|
||||
platform === 'win32'
|
||||
? 'C:\\Program Files\\Agent Teams AI\\agent-teams-ai.exe'
|
||||
: '/opt/Agent Teams AI/agent-teams-ai';
|
||||
setPackagedMode(true, '3.0.0');
|
||||
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||
createdDirs.push(resourcesDir);
|
||||
createPackagedServerBundle(resourcesDir, '// packaged server');
|
||||
setResourcesPath(resourcesDir);
|
||||
hoisted.execCliMock.mockResolvedValue({
|
||||
stdout: 'agent-teams-electron-node-ok',
|
||||
stderr: '',
|
||||
});
|
||||
expect(server?.command).toBe(electronBinary);
|
||||
expect(server?.args).toEqual([expectedEntry]);
|
||||
expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1');
|
||||
expect(hoisted.execCliMock).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.execCliMock).toHaveBeenCalledWith(
|
||||
electronBinary,
|
||||
['-e', 'process.stdout.write("agent-teams-electron-node-ok")'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor);
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: electronBinary,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const launchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
const builder = new TeamMcpConfigBuilder();
|
||||
const configPath = await builder.writeConfigFile();
|
||||
createdPaths.push(configPath);
|
||||
const server = readGeneratedServer(configPath);
|
||||
const expectedEntry = path.join(tempAppData, 'mcp-server', '3.0.0', 'index.js');
|
||||
|
||||
expect(launchSpec).toEqual({
|
||||
command: electronBinary,
|
||||
args: [expectedEntry],
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
});
|
||||
expect(server?.command).toBe(electronBinary);
|
||||
expect(server?.args).toEqual([expectedEntry]);
|
||||
expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1');
|
||||
expect(hoisted.execCliMock).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.execCliMock).toHaveBeenCalledWith(
|
||||
electronBinary,
|
||||
['-e', 'process.stdout.write("agent-teams-electron-node-ok")'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor);
|
||||
}
|
||||
if (execPathDescriptor) {
|
||||
Object.defineProperty(process, 'execPath', execPathDescriptor);
|
||||
}
|
||||
}
|
||||
if (execPathDescriptor) {
|
||||
Object.defineProperty(process, 'execPath', execPathDescriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -436,7 +448,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
||||
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
|
||||
expect(command).toBe('node');
|
||||
return { stdout: '/strict-shell-node-bin/node', stderr: '' };
|
||||
return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' };
|
||||
}
|
||||
throw new Error(`spawn ${command} ENOENT`);
|
||||
});
|
||||
|
|
@ -464,6 +476,93 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('prefers strict shell env lookup over fast Node lookup from a minimal GUI PATH', async () => {
|
||||
mockBuiltWorkspaceEntryAvailable();
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter);
|
||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
|
||||
PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter),
|
||||
HOME: '/Users/tester',
|
||||
});
|
||||
hoisted.execCliMock.mockImplementation(async (command, _args, options) => {
|
||||
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
||||
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
|
||||
expect(command).toBe('node');
|
||||
return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' };
|
||||
}
|
||||
return { stdout: nodeRuntimeProbeStdout('/fast/node'), stderr: '' };
|
||||
});
|
||||
|
||||
try {
|
||||
const builder = new TeamMcpConfigBuilder();
|
||||
const configPath = await builder.writeConfigFile();
|
||||
createdPaths.push(configPath);
|
||||
|
||||
expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node');
|
||||
expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: 'mcp-node-runtime' })
|
||||
);
|
||||
} finally {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to strict shell env lookup when the fast Node runtime is too old', async () => {
|
||||
mockBuiltWorkspaceEntryAvailable();
|
||||
const previousNodeBinary = process.env.NODE_BINARY;
|
||||
const previousNpmNodeExecPath = process.env.npm_node_execpath;
|
||||
const previousPath = process.env.PATH;
|
||||
delete process.env.NODE_BINARY;
|
||||
delete process.env.npm_node_execpath;
|
||||
process.env.PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter);
|
||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
|
||||
PATH: ['/strict-shell-node-bin', '/usr/bin'].join(path.delimiter),
|
||||
HOME: '/Users/tester',
|
||||
});
|
||||
hoisted.execCliMock.mockImplementation(async (command, _args, options) => {
|
||||
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
||||
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
|
||||
expect(command).toBe('node');
|
||||
return {
|
||||
stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node', '20.11.0'),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
return { stdout: nodeRuntimeProbeStdout('/usr/bin/node', '18.19.0'), stderr: '' };
|
||||
});
|
||||
|
||||
try {
|
||||
const builder = new TeamMcpConfigBuilder();
|
||||
const configPath = await builder.writeConfigFile();
|
||||
createdPaths.push(configPath);
|
||||
|
||||
expect(readGeneratedServer(configPath)?.command).toBe('/strict-shell-node-bin/node');
|
||||
expect(hoisted.resolveInteractiveShellEnvMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: 'mcp-node-runtime' })
|
||||
);
|
||||
} finally {
|
||||
if (previousNodeBinary === undefined) {
|
||||
delete process.env.NODE_BINARY;
|
||||
} else {
|
||||
process.env.NODE_BINARY = previousNodeBinary;
|
||||
}
|
||||
if (previousNpmNodeExecPath === undefined) {
|
||||
delete process.env.npm_node_execpath;
|
||||
} else {
|
||||
process.env.npm_node_execpath = previousNpmNodeExecPath;
|
||||
}
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to strict shell env lookup when fast Node lookup reports an empty path', async () => {
|
||||
mockBuiltWorkspaceEntryAvailable();
|
||||
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
|
||||
|
|
@ -475,7 +574,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
const env = options?.env as NodeJS.ProcessEnv | undefined;
|
||||
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
|
||||
expect(command).toBe('node');
|
||||
return { stdout: '/strict-shell-node-bin/node', stderr: '' };
|
||||
return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' };
|
||||
}
|
||||
if (!returnedEmptyPath) {
|
||||
returnedEmptyPath = true;
|
||||
|
|
@ -500,7 +599,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
process.env.NODE_BINARY = '/explicit/node';
|
||||
hoisted.execCliMock.mockImplementationOnce(async (command) => {
|
||||
expect(command).toBe('/explicit/node');
|
||||
return { stdout: '/explicit/node', stderr: '' };
|
||||
return { stdout: nodeRuntimeProbeStdout('/explicit/node'), stderr: '' };
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -643,7 +742,10 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
createdPaths.push(configPath);
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
mcpServers: Record<string, { command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> }>;
|
||||
mcpServers: Record<
|
||||
string,
|
||||
{ command?: string; args?: string[]; enabled?: boolean; env?: Record<string, string> }
|
||||
>;
|
||||
};
|
||||
|
||||
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
|
||||
|
|
@ -744,7 +846,10 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
createdPaths.push(configPath);
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
mcpServers: Record<string, { command?: string; args?: string[]; type?: string; url?: string }>;
|
||||
mcpServers: Record<
|
||||
string,
|
||||
{ command?: string; args?: string[]; type?: string; url?: string }
|
||||
>;
|
||||
};
|
||||
|
||||
expect(Object.keys(parsed.mcpServers).sort()).toEqual(['agent-teams', 'github', 'linear']);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|||
vi.mock('@main/utils/childProcess', () => ({
|
||||
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
||||
return { stdout: process.execPath, stderr: '' };
|
||||
return {
|
||||
stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (args.includes('model') && args.includes('list')) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,13 @@ vi.mock('@main/services/team/TeamTaskReader', () => ({
|
|||
|
||||
vi.mock('@main/utils/childProcess', () => ({
|
||||
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
||||
return {
|
||||
stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'model') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -80,6 +80,13 @@ async function setupRunningTeam(teamName: string) {
|
|||
const { child, writeSpy } = createFakeChild();
|
||||
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||
vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => {
|
||||
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
||||
return {
|
||||
stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
|
||||
const providerIndex = args.indexOf('--provider');
|
||||
const providerId = providerIndex >= 0 ? args[providerIndex + 1] : 'anthropic';
|
||||
if (args[0] === 'model' && args[1] === 'list') {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
|||
}));
|
||||
|
||||
const defaultExecCliMockImplementation = async (_binaryPath: string | null, args: string[]) => {
|
||||
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
||||
return {
|
||||
stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (args[0] === 'model') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|||
vi.mock('@main/utils/childProcess', () => ({
|
||||
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
|
||||
if (args[0] === '-e' && args[1]?.includes('process.execPath')) {
|
||||
return { stdout: process.execPath, stderr: '' };
|
||||
return {
|
||||
stdout: JSON.stringify({ execPath: process.execPath, version: process.versions.node }),
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (args.includes('model') && args.includes('list')) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
|
|
@ -173,6 +174,14 @@ vi.mock('@renderer/store', () => {
|
|||
import { CliStatusBanner } from '@renderer/components/dashboard/CliStatusBanner';
|
||||
import { CliStatusSection } from '@renderer/components/settings/sections/CliStatusSection';
|
||||
|
||||
async function flushLazyImports(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function createInstalledCliStatus(
|
||||
overrides?: Partial<Record<string, unknown>>
|
||||
): Record<string, unknown> {
|
||||
|
|
@ -305,6 +314,30 @@ function createCodexNativeRolloutProvider(
|
|||
};
|
||||
}
|
||||
|
||||
function createDeferredMultimodelProvider(
|
||||
providerId: 'anthropic' | 'codex' | 'opencode',
|
||||
displayName: string
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
providerId,
|
||||
displayName,
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: CLI_PROVIDER_STATUS_DEFERRED_MESSAGE,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: providerId !== 'opencode',
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: null,
|
||||
availableBackends: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('CLI status visibility during completed install state', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
|
|
@ -450,6 +483,192 @@ describe('CLI status visibility during completed install state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps the dashboard terminal modal unmounted until login is requested', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
authLoggedIn: false,
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await flushLazyImports();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="terminal-modal"]')).toBeNull();
|
||||
|
||||
const loginButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Login'
|
||||
);
|
||||
expect(loginButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
loginButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flushLazyImports();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="terminal-modal"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushLazyImports();
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the installer terminal log only while installation is active', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'installing';
|
||||
storeState.cliInstallerRawChunks = ['installing...\n'];
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await flushLazyImports();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('terminal-log');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushLazyImports();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows deferred multimodel provider snapshots as pending instead of disconnected', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
authLoggedIn: false,
|
||||
authStatusChecking: true,
|
||||
providers: [
|
||||
createDeferredMultimodelProvider('anthropic', 'Anthropic'),
|
||||
createDeferredMultimodelProvider('codex', 'Codex'),
|
||||
createDeferredMultimodelProvider('opencode', 'OpenCode'),
|
||||
],
|
||||
});
|
||||
storeState.cliProviderStatusLoading = {
|
||||
anthropic: true,
|
||||
codex: true,
|
||||
opencode: true,
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Checking providers...');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
expect(host.textContent).not.toContain('Providers: 0/3 connected');
|
||||
expect(host.textContent).not.toContain('Models unavailable for this runtime build');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps connected provider details visible while a refresh is in flight', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
authLoggedIn: true,
|
||||
authStatusChecking: true,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Connected via Anthropic subscription',
|
||||
models: ['claude-3-5-sonnet'],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
backend: null,
|
||||
},
|
||||
createCodexNativeRolloutProvider({
|
||||
state: 'ready',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
models: ['gpt-5-codex'],
|
||||
}),
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
modelCatalog: null,
|
||||
modelCatalogRefreshState: 'idle',
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: {
|
||||
dynamic: true,
|
||||
source: 'runtime',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
storeState.cliProviderStatusLoading = {
|
||||
codex: true,
|
||||
opencode: true,
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Providers: 3/3 connected');
|
||||
expect(host.textContent).toContain('ChatGPT account ready');
|
||||
expect(host.textContent).toContain('Loading models...');
|
||||
expect(host.textContent).not.toContain('Checking...');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an OpenCode install action on the dashboard when the OpenCode CLI is missing', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
|
|
@ -1089,6 +1308,15 @@ describe('CLI status visibility during completed install state', () => {
|
|||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed')));
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
authLoggedIn: true,
|
||||
providers: [createCodexNativeRolloutProvider()],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -1099,6 +1327,19 @@ describe('CLI status visibility during completed install state', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(providerRuntimeSettingsDialogProps).toBeNull();
|
||||
|
||||
const manageButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Manage'
|
||||
);
|
||||
expect(manageButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
manageButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const onSelectBackend = providerRuntimeSettingsDialogProps?.onSelectBackend;
|
||||
expect(onSelectBackend).toBeTypeOf('function');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface StoreState {
|
||||
cliStatus: Record<string, unknown> | null;
|
||||
|
|
@ -28,9 +27,9 @@ interface StoreState {
|
|||
|
||||
const storeState = {} as StoreState;
|
||||
const codexAccountHookState = {
|
||||
snapshot: null as CodexAccountSnapshotDto | null,
|
||||
snapshot: null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
error: null,
|
||||
refresh: vi.fn(() => Promise.resolve(undefined)),
|
||||
startChatgptLogin: vi.fn(() => Promise.resolve(true)),
|
||||
cancelChatgptLogin: vi.fn(() => Promise.resolve(true)),
|
||||
|
|
@ -60,18 +59,15 @@ vi.mock('@renderer/store', () => ({
|
|||
|
||||
import { GlobalProviderStatusHeader } from '@renderer/components/common/GlobalProviderStatusHeader';
|
||||
|
||||
function createProvider(
|
||||
overrides: Partial<Record<string, unknown>> & {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
}
|
||||
): Record<string, unknown> {
|
||||
function createProvider(overrides: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
detailMessage: null,
|
||||
models: [],
|
||||
modelVerificationState: 'idle',
|
||||
|
|
@ -80,10 +76,7 @@ function createProvider(
|
|||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
plugins: { status: 'unsupported' },
|
||||
mcp: { status: 'unsupported' },
|
||||
},
|
||||
extensions: {},
|
||||
},
|
||||
backend: null,
|
||||
availableBackends: [],
|
||||
|
|
@ -127,18 +120,15 @@ function setFocusedTab(type: string): void {
|
|||
describe('GlobalProviderStatusHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = null;
|
||||
storeState.cliStatus = createMultimodelStatus([createProvider({})]);
|
||||
storeState.cliStatusLoading = false;
|
||||
storeState.cliProviderStatusLoading = {};
|
||||
storeState.cliProviderStatusLoading = { anthropic: true };
|
||||
storeState.appConfig = {
|
||||
general: {
|
||||
multimodelEnabled: true,
|
||||
},
|
||||
};
|
||||
setFocusedTab('team');
|
||||
codexAccountHookState.snapshot = null;
|
||||
codexAccountHookState.loading = false;
|
||||
codexAccountHookState.error = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -146,17 +136,7 @@ describe('GlobalProviderStatusHeader', () => {
|
|||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows loading providers on non-dashboard screens', async () => {
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: true };
|
||||
|
||||
it('shows provider activity on non-dashboard screens', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
|
@ -176,18 +156,8 @@ describe('GlobalProviderStatusHeader', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('hides on dashboard tabs', async () => {
|
||||
it('hides on dashboard screens', async () => {
|
||||
setFocusedTab('dashboard');
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: true };
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
|
@ -204,165 +174,4 @@ describe('GlobalProviderStatusHeader', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps completed providers visible as Checked while the same cycle still has loading work, then hides when clean', async () => {
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: true, codex: true };
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Not connected',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: false, codex: true };
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Checked');
|
||||
expect(host.textContent).toContain('Codex');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Not connected',
|
||||
}),
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'ChatGPT account ready',
|
||||
authenticated: true,
|
||||
authMethod: 'chatgpt',
|
||||
}),
|
||||
]);
|
||||
storeState.cliProviderStatusLoading = { anthropic: false, codex: false };
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('stays visible for provider errors after loading finishes', async () => {
|
||||
storeState.cliStatus = createMultimodelStatus([
|
||||
createProvider({
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
verificationState: 'error',
|
||||
statusMessage: 'Failed to refresh anthropic status',
|
||||
}),
|
||||
]);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Failed to refresh anthropic status');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('masks the negative Codex bootstrap snapshot while placeholder loading is still active', async () => {
|
||||
storeState.cliStatus = null;
|
||||
storeState.cliStatusLoading = true;
|
||||
codexAccountHookState.snapshot = {
|
||||
preferredAuthMode: 'chatgpt',
|
||||
effectiveAuthMode: null,
|
||||
launchAllowed: false,
|
||||
launchIssueMessage: 'Connect a ChatGPT account to use your Codex subscription.',
|
||||
launchReadinessState: 'missing_auth',
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
managedAccount: null,
|
||||
apiKey: {
|
||||
available: false,
|
||||
source: null,
|
||||
sourceLabel: null,
|
||||
},
|
||||
requiresOpenaiAuth: true,
|
||||
localAccountArtifactsPresent: false,
|
||||
localActiveChatgptAccountPresent: false,
|
||||
login: {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
},
|
||||
rateLimits: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalProviderStatusHeader));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Codex');
|
||||
expect(host.textContent).toContain('Checking...');
|
||||
expect(host.textContent).not.toContain(
|
||||
'Connect a ChatGPT account to use your Codex subscription.'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||