merge: dev into main

This commit is contained in:
777genius 2026-05-24 02:56:32 +03:00
commit bff0486977
109 changed files with 11652 additions and 5538 deletions

View file

@ -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
View 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

View file

@ -14,6 +14,7 @@ on:
- 'runtime.lock.json' - 'runtime.lock.json'
- 'test/**' - 'test/**'
- '.github/workflows/**' - '.github/workflows/**'
- '.github/dependabot.yml'
- 'pnpm-workspace.yaml' - 'pnpm-workspace.yaml'
- 'package.json' - 'package.json'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
@ -34,6 +35,7 @@ on:
- 'runtime.lock.json' - 'runtime.lock.json'
- 'test/**' - 'test/**'
- '.github/workflows/**' - '.github/workflows/**'
- '.github/dependabot.yml'
- 'pnpm-workspace.yaml' - 'pnpm-workspace.yaml'
- 'package.json' - 'package.json'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
@ -66,11 +68,16 @@ jobs:
cache: pnpm cache: pnpm
- name: Restore pnpm node-gyp executable bit - 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 - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Audit dependencies
run: pnpm audit --audit-level high
- name: Restore ESLint cache - name: Restore ESLint cache
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
@ -99,7 +106,9 @@ jobs:
cache: pnpm cache: pnpm
- name: Restore pnpm node-gyp executable bit - 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 - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile

View file

@ -31,6 +31,10 @@ jobs:
working-directory: landing working-directory: landing
run: npm ci run: npm ci
- name: Audit dependencies
working-directory: landing
run: npm audit --audit-level high
- name: Generate static site - name: Generate static site
working-directory: landing working-directory: landing
env: env:

View file

@ -45,6 +45,12 @@ jobs:
node-version: 22 node-version: 22
cache: pnpm 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 - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@ -336,6 +342,11 @@ jobs:
with: with:
python-version: '3.11' 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 - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@ -452,6 +463,12 @@ jobs:
with: with:
python-version: '3.11' 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 - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@ -573,6 +590,11 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y libarchive-tools rpm xvfb 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 - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile

View file

@ -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 - **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 - **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> <details>
<summary><strong>What happens if an agent gets stuck?</strong></summary> <summary><strong>What happens if an agent gets stuck?</strong></summary>
<br /> <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>
<details> <details>
<summary><strong>Does it support multiple projects and teams?</strong></summary> <summary><strong>Does it support multiple projects and teams?</strong></summary>
<br /> <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> </details>
--- ---

View file

@ -1819,10 +1819,6 @@
scale(var(--agent-tablet-scale)); scale(var(--agent-tablet-scale));
} }
.cyber-agent__card {
display: none;
}
.cyber-feature-rail-shell { .cyber-feature-rail-shell {
width: 100%; 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) { @media (max-width: 767px) {
.cyber-hero { .cyber-hero {
padding: 84px 0 36px; padding: 84px 0 36px;
@ -1889,12 +1967,14 @@
.cyber-hero__layout { .cyber-hero__layout {
min-width: 0; min-width: 0;
gap: 0;
overflow: hidden; overflow: hidden;
} }
.cyber-hero__copy { .cyber-hero__copy {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
padding-bottom: 0;
} }
.cyber-hero__brand-lockup { .cyber-hero__brand-lockup {
@ -1970,14 +2050,14 @@
.cyber-scene { .cyber-scene {
min-height: auto; min-height: auto;
aspect-ratio: auto; aspect-ratio: auto;
padding: 92px 0 12px; padding: 96px 0 14px;
transform: none; transform: none;
} }
.cyber-hero__scene { .cyber-hero__scene {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
margin-top: 18px; margin-top: 8px;
overflow: hidden; overflow: hidden;
} }
@ -1996,18 +2076,20 @@
.cyber-scene__robots { .cyber-scene__robots {
inset: 0 0 auto; inset: 0 0 auto;
height: 92px; height: 96px;
display: flex; display: flex;
align-items: flex-end;
justify-content: center; justify-content: center;
gap: 10px; gap: clamp(18px, 6vw, 34px);
} }
.cyber-agent { .cyber-agent {
position: relative; position: relative;
flex: 0 0 auto;
display: none; display: none;
left: auto; left: auto;
top: auto; top: auto;
width: 76px; width: clamp(58px, 18vw, 74px);
transform: none; transform: none;
} }
@ -2015,11 +2097,46 @@
display: block; 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 { .cyber-agent__float {
animation-duration: 6s; 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 { .cyber-agent__eyes {
display: none; display: none;
} }
@ -2034,6 +2151,33 @@
padding: 0 4px; 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 { .cyber-feature-rail__item {
grid-template-columns: 48px 44px minmax(0, 1fr); grid-template-columns: 48px 44px minmax(0, 1fr);
grid-template-rows: auto; grid-template-rows: auto;

View file

@ -173,6 +173,7 @@ function shouldUseBackgroundVideo() {
backgroundPlaybackId.value && backgroundPlaybackId.value &&
isVisible && isVisible &&
!motionQuery?.matches && !motionQuery?.matches &&
!mobileQuery?.matches &&
!hasBackgroundVideoError.value, !hasBackgroundVideoError.value,
); );
} }
@ -248,7 +249,7 @@ onMounted(() => {
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
mobileQuery = window.matchMedia("(max-width: 700px)"); mobileQuery = window.matchMedia("(max-width: 700px)");
motionQuery.addEventListener("change", syncMotionState); motionQuery.addEventListener("change", syncMotionState);
mobileQuery.addEventListener("change", syncGradient); mobileQuery.addEventListener("change", syncMotionState);
heroObserver = new IntersectionObserver( heroObserver = new IntersectionObserver(
([entry]) => { ([entry]) => {
@ -267,7 +268,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
heroObserver?.disconnect(); heroObserver?.disconnect();
motionQuery?.removeEventListener("change", syncMotionState); motionQuery?.removeEventListener("change", syncMotionState);
mobileQuery?.removeEventListener("change", syncGradient); mobileQuery?.removeEventListener("change", syncMotionState);
stopBackgroundVideo(); stopBackgroundVideo();
destroyGradient(); destroyGradient();
}); });

View file

@ -9,6 +9,8 @@ const menuOpen = ref(false);
const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`; const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`;
const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/')); const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/'));
const isRu = computed(() => locale.value === 'ru'); const isRu = computed(() => locale.value === 'ru');
const openMenuLabel = computed(() => (isRu.value ? 'Открыть меню' : 'Open menu'));
const closeMenuLabel = computed(() => (isRu.value ? 'Закрыть меню' : 'Close menu'));
const navItems = computed(() => [ const navItems = computed(() => [
{ href: '#screenshots', label: t('nav.screenshots'), shortLabel: isRu.value ? 'Скрины' : 'Shots' }, { href: '#screenshots', label: t('nav.screenshots'), shortLabel: isRu.value ? 'Скрины' : 'Shots' },
@ -134,7 +136,7 @@ const navItems = computed(() => [
<ThemeToggle /> <ThemeToggle />
</div> </div>
<div class="app-header__mobile-actions"> <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"> <Teleport to="body">
<Transition name="mobile-menu-fade"> <Transition name="mobile-menu-fade">
<div v-if="menuOpen" class="mobile-menu-overlay" @click.self="menuOpen = false"> <div v-if="menuOpen" class="mobile-menu-overlay" @click.self="menuOpen = false">
@ -142,7 +144,13 @@ const navItems = computed(() => [
<div class="mobile-menu__header"> <div class="mobile-menu__header">
<AppLogo /> <AppLogo />
<div style="flex: 1" /> <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> </div>
<hr class="mobile-menu__divider"> <hr class="mobile-menu__divider">
<nav class="mobile-menu__list"> <nav class="mobile-menu__list">
@ -825,10 +833,43 @@ const navItems = computed(() => [
} }
.mobile-menu__header { .mobile-menu__header {
position: sticky;
top: 0;
z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; 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 { .mobile-menu__divider {

View file

@ -55,7 +55,7 @@ const ruNotes: Record<string, string> = {
'5 columns, real-time': '5 колонок, в реальном времени', '5 columns, real-time': '5 колонок, в реальном времени',
'Dashboard, not Kanban': 'Панель, не канбан', 'Dashboard, not Kanban': 'Панель, не канбан',
'7 columns, drag-and-drop': '7 колонок, перетаскивание', '7 columns, drag-and-drop': '7 колонок, перетаскивание',
'Инструменты, ход рассуждений и таймлайн': 'Инструменты, ход рассуждений и таймлайн', 'Tools, reasoning trace, and timeline': 'Инструменты, ход рассуждений и таймлайн',
'Feed, metrics, dashboard': 'Лента, метрики, панель', 'Feed, metrics, dashboard': 'Лента, метрики, панель',
'Agent chat + terminal': 'Чат агента и терминал', 'Agent chat + terminal': 'Чат агента и терминал',
'View, stop, open URLs': 'Просмотр, остановка, открытие URL', 'View, stop, open URLs': 'Просмотр, остановка, открытие URL',
@ -271,7 +271,7 @@ const rows = computed<ComparisonRow[]>(() => [
}, },
{ {
feature: t('comparison.features.execLog'), 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') }, gastown: { status: 'partial', note: note('Feed, metrics, dashboard') },
paperclip: { status: 'yes', note: note('Run transcripts + audit log') }, paperclip: { status: 'yes', note: note('Run transcripts + audit log') },
cursor: { status: 'partial', note: note('Agent chat + terminal') }, cursor: { status: 'partial', note: note('Agent chat + terminal') },

View file

@ -84,6 +84,14 @@ const faqIcons = [
<style scoped> <style scoped>
.faq-section { .faq-section {
position: relative; 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 { .faq-section__header {
@ -100,7 +108,7 @@ const faqIcons = [
letter-spacing: -0.03em; letter-spacing: -0.03em;
line-height: 1.15; line-height: 1.15;
margin-bottom: 16px; margin-bottom: 16px;
background: linear-gradient(135deg, #e0e6ff 0%, #ffd700 100%); background: var(--faq-title-gradient);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
@ -108,7 +116,7 @@ const faqIcons = [
.faq-section__subtitle { .faq-section__subtitle {
font-size: 1.1rem; font-size: 1.1rem;
color: #8892b0; color: var(--faq-subtitle-color);
line-height: 1.6; line-height: 1.6;
margin: 0; margin: 0;
} }
@ -134,9 +142,10 @@ const faqIcons = [
.faq-section__panel { .faq-section__panel {
border-radius: 16px !important; border-radius: 16px !important;
background: rgba(10, 10, 15, 0.8) !important; background: var(--faq-panel-bg) !important;
border: 1px solid rgba(0, 240, 255, 0.08) !important; border: 1px solid var(--faq-panel-border) !important;
backdrop-filter: blur(12px); 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; transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
overflow: hidden; overflow: hidden;
animation: faqFadeIn 0.5s ease both; animation: faqFadeIn 0.5s ease both;
@ -145,8 +154,8 @@ const faqIcons = [
.faq-section__panel:hover { .faq-section__panel:hover {
transform: translateY(-2px); transform: translateY(-2px);
border-color: rgba(0, 240, 255, 0.2) !important; border-color: var(--faq-panel-hover-border) !important;
box-shadow: 0 8px 32px rgba(0, 240, 255, 0.06); box-shadow: var(--faq-panel-hover-shadow);
} }
.faq-section__panel::after { .faq-section__panel::after {
@ -160,6 +169,8 @@ const faqIcons = [
.faq-section__panel-title { .faq-section__panel-title {
padding: 20px 24px !important; padding: 20px 24px !important;
min-height: unset !important; min-height: unset !important;
background: transparent !important;
color: var(--faq-panel-question) !important;
} }
:deep(.faq-section__panel-title .v-expansion-panel-title__overlay) { :deep(.faq-section__panel-title .v-expansion-panel-title__overlay) {
@ -198,17 +209,18 @@ const faqIcons = [
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
color: #e0e6ff; color: var(--faq-panel-question);
} }
:deep(.faq-section__panel-text .v-expansion-panel-text__wrapper) { :deep(.faq-section__panel-text .v-expansion-panel-text__wrapper) {
padding: 0 24px 20px 82px !important; padding: 0 24px 20px 82px !important;
background: transparent !important;
} }
.faq-section__answer { .faq-section__answer {
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.7; line-height: 1.7;
color: #8892b0; color: var(--faq-panel-answer);
} }
.faq-section__answer :deep(a) { .faq-section__answer :deep(a) {
@ -300,32 +312,28 @@ const faqIcons = [
} }
/* Light Theme */ /* Light Theme */
.v-theme--light .faq-section__title { :global(.v-theme--light) .faq-section,
background: linear-gradient(135deg, #1e293b 0%, #d97706 100%); :global(html.light) .faq-section {
-webkit-background-clip: text; --faq-title-gradient: linear-gradient(135deg, #1e293b 0%, #d97706 100%);
background-clip: text; --faq-subtitle-color: #475569;
-webkit-text-fill-color: transparent; --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 { :global(.v-theme--dark) .faq-section,
color: #475569; :global(html.dark) .faq-section {
} --faq-title-gradient: linear-gradient(135deg, #e0e6ff 0%, #ffd700 100%);
--faq-subtitle-color: #8892b0;
.v-theme--light .faq-section__panel { --faq-panel-bg: rgba(10, 10, 15, 0.8);
background: rgba(255, 255, 255, 0.75) !important; --faq-panel-border: rgba(0, 240, 255, 0.08);
border-color: rgba(0, 0, 0, 0.06) !important; --faq-panel-question: #e0e6ff;
} --faq-panel-answer: #8892b0;
--faq-panel-hover-shadow: 0 8px 32px rgba(0, 240, 255, 0.06);
.v-theme--light .faq-section__panel:hover { --faq-panel-hover-border: rgba(0, 240, 255, 0.2);
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;
} }
@media (max-width: 960px) { @media (max-width: 960px) {

View file

@ -29,10 +29,29 @@ const muxPlayerUrl = computed(() => {
url.searchParams.set("video-title", muxVideoTitle.value); url.searchParams.set("video-title", muxVideoTitle.value);
return url.toString(); 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 isLoaded = ref(false);
const hasError = 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 loadFallbackTimer: ReturnType<typeof setTimeout> | null = null;
let mobileQuery: MediaQueryList | null = null;
function clearLoadFallback() { function clearLoadFallback() {
if (!loadFallbackTimer) return; if (!loadFallbackTimer) return;
@ -51,11 +70,25 @@ function markError() {
clearLoadFallback(); clearLoadFallback();
} }
function syncMobileViewport() {
isMobileViewport.value = Boolean(mobileQuery?.matches);
}
function activatePlayer() {
playerActivated.value = true;
}
onMounted(() => { onMounted(() => {
mobileQuery = window.matchMedia("(max-width: 700px)");
syncMobileViewport();
mobileQuery.addEventListener("change", syncMobileViewport);
loadFallbackTimer = setTimeout(markLoaded, 2500); loadFallbackTimer = setTimeout(markLoaded, 2500);
}); });
onUnmounted(clearLoadFallback); onUnmounted(() => {
mobileQuery?.removeEventListener("change", syncMobileViewport);
clearLoadFallback();
});
</script> </script>
<template> <template>
@ -70,7 +103,7 @@ onUnmounted(clearLoadFallback);
<ClientOnly> <ClientOnly>
<iframe <iframe
v-if="muxPlayerUrl && !hasError" v-if="shouldShowPlayer"
class="hero-video__player" class="hero-video__player"
:class="{ 'hero-video__player--loaded': isLoaded }" :class="{ 'hero-video__player--loaded': isLoaded }"
:src="muxPlayerUrl" :src="muxPlayerUrl"
@ -94,7 +127,21 @@ onUnmounted(clearLoadFallback);
</template> </template>
</ClientOnly> </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-pulse" />
<div class="hero-video__skeleton-content"> <div class="hero-video__skeleton-content">
<div class="hero-video__skeleton-spinner" /> <div class="hero-video__skeleton-spinner" />
@ -265,6 +312,69 @@ onUnmounted(clearLoadFallback);
opacity: 1; 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 { .hero-video__skeleton {
position: absolute; position: absolute;
inset: 0; inset: 0;
@ -398,6 +508,10 @@ onUnmounted(clearLoadFallback);
border-radius: 10px; border-radius: 10px;
} }
.hero-video__poster {
border-radius: 10px;
}
.hero-video__edge { .hero-video__edge {
left: 12px; left: 12px;
right: 12px; right: 12px;

View file

@ -42,6 +42,10 @@ export const useBrowserTheme = () => {
const applyDocumentTheme = (name: ThemeName) => { const applyDocumentTheme = (name: ThemeName) => {
if (!import.meta.client) return; 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) => { document.querySelectorAll(".v-application").forEach((app) => {
app.classList.toggle("v-theme--dark", name === "dark"); app.classList.toggle("v-theme--dark", name === "dark");
app.classList.toggle("v-theme--light", name === "light"); app.classList.toggle("v-theme--light", name === "light");

4374
landing/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -24,7 +24,7 @@
"@nuxtjs/i18n": "^9.5.6", "@nuxtjs/i18n": "^9.5.6",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"@vueuse/nuxt": "^10.11.1", "@vueuse/nuxt": "^10.11.1",
"nuxt": "^3.20.2", "nuxt": "^3.21.6",
"nuxt-icon": "^0.6.10", "nuxt-icon": "^0.6.10",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"swiper": "^12.1.2", "swiper": "^12.1.2",
@ -38,7 +38,7 @@
"@nuxt/eslint": "^1.12.1", "@nuxt/eslint": "^1.12.1",
"@shikijs/transformers": "3.22.0", "@shikijs/transformers": "3.22.0",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"eslint": "^9.39.2", "eslint": "^9.39.4",
"medium-zoom": "^1.1.0", "medium-zoom": "^1.1.0",
"prettier": "^3.8.0", "prettier": "^3.8.0",
"sass": "^1.97.2", "sass": "^1.97.2",

View file

@ -37,7 +37,7 @@
}, },
"dependencies": { "dependencies": {
"agent-teams-controller": "workspace:*", "agent-teams-controller": "workspace:*",
"fastmcp": "^3.34.0", "fastmcp": "^3.35.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,7 +1,7 @@
{ {
"name": "agent-teams-ai", "name": "agent-teams-ai",
"type": "module", "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", "description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": { "author": {
@ -198,7 +198,7 @@
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^4.0.3", "@electron/rebuild": "^4.0.4",
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0", "@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@sentry/vite-plugin": "^5.1.1", "@sentry/vite-plugin": "^5.1.1",
@ -216,7 +216,7 @@
"electron": "^40.10.0", "electron": "^40.10.0",
"electron-builder": "^26.8.1", "electron-builder": "^26.8.1",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"eslint": "^9.39.2", "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4", "eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^5.3.1", "eslint-plugin-boundaries": "^5.3.1",
@ -230,18 +230,18 @@
"eslint-plugin-sonarjs": "^3.0.6", "eslint-plugin-sonarjs": "^3.0.6",
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.2",
"globals": "^17.2.0", "globals": "^17.2.0",
"happy-dom": "^20.0.2", "happy-dom": "^20.9.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"knip": "^5.82.1", "knip": "^5.82.1",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"postcss": "^8.4.35", "postcss": "^8.5.10",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.54.0",
"vite": "^5.4.2", "vite": "^6.4.2",
"vitest": "^3.1.4" "vitest": "^3.1.4"
}, },
"build": { "build": {
@ -251,10 +251,29 @@
"output": "release" "output": "release"
}, },
"files": [ "files": [
"out/renderer/**", {
"dist-electron/**", "from": "out/renderer",
"package.json", "to": "out/renderer",
"!**/*.map" "filter": [
"**/*",
"!**/*.map"
]
},
{
"from": "dist-electron",
"to": "dist-electron",
"filter": [
"**/*",
"!**/*.map"
]
},
{
"from": ".",
"to": ".",
"filter": [
"package.json"
]
}
], ],
"asar": true, "asar": true,
"asarUnpack": [ "asarUnpack": [
@ -311,6 +330,7 @@
"target": [ "target": [
"nsis" "nsis"
], ],
"executableName": "AgentTeamsAI",
"icon": "resources/icons/win/icon.ico" "icon": "resources/icons/win/icon.ico"
}, },
"linux": { "linux": {
@ -348,7 +368,8 @@
"artifactName": "Agent.Teams.AI.Setup.${version}.${ext}", "artifactName": "Agent.Teams.AI.Setup.${version}.${ext}",
"oneClick": false, "oneClick": false,
"perMachine": false, "perMachine": false,
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true,
"shortcutName": "Agent Teams AI"
}, },
"publish": [ "publish": [
{ {
@ -362,8 +383,47 @@
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"pnpm": { "pnpm": {
"overrides": { "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", "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": [ "onlyBuiltDependencies": [
"electron", "electron",

File diff suppressed because it is too large Load diff

View file

@ -3,5 +3,10 @@ packages:
- mcp-server - mcp-server
- landing - landing
- packages/agent-graph - packages/agent-graph
minimumReleaseAge: 4320
strictPeerDependencies: true
peerDependencyRules:
allowedVersions:
"@nuxt/schema": "3.21.6"
ignoredBuiltDependencies: ignoredBuiltDependencies:
- esbuild - esbuild

View file

@ -317,6 +317,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_max_reasoning_effort": true, "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
@ -347,6 +348,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_max_reasoning_effort": true, "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
@ -377,6 +379,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_max_reasoning_effort": true, "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
@ -406,6 +409,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_max_reasoning_effort": true, "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
@ -435,6 +439,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_max_reasoning_effort": true, "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
@ -634,6 +639,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
"global.anthropic.claude-sonnet-4-6": { "global.anthropic.claude-sonnet-4-6": {
@ -664,6 +670,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
"us.anthropic.claude-sonnet-4-6": { "us.anthropic.claude-sonnet-4-6": {
@ -694,6 +701,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
"eu.anthropic.claude-sonnet-4-6": { "eu.anthropic.claude-sonnet-4-6": {
@ -723,6 +731,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
"au.anthropic.claude-sonnet-4-6": { "au.anthropic.claude-sonnet-4-6": {
@ -752,6 +761,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
"jp.anthropic.claude-sonnet-4-6": { "jp.anthropic.claude-sonnet-4-6": {
@ -781,6 +791,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_output_config": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
"anthropic.claude-sonnet-4-20250514-v1:0": { "anthropic.claude-sonnet-4-20250514-v1:0": {
@ -1085,6 +1096,7 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 159, "tool_use_system_prompt_tokens": 159,
"supports_output_config": true,
"supports_max_reasoning_effort": true, "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
@ -1182,6 +1194,7 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_output_config": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
"bedrock/ap-northeast-1/anthropic.claude-instant-v1": { "bedrock/ap-northeast-1/anthropic.claude-instant-v1": {
@ -1761,6 +1774,7 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_output_config": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
"claude-sonnet-4-5-20250929-v1:0": { "claude-sonnet-4-5-20250929-v1:0": {
@ -1958,6 +1972,7 @@
"us": 1.1, "us": 1.1,
"fast": 6 "fast": 6
}, },
"supports_output_config": true,
"supports_max_reasoning_effort": true, "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
@ -1993,7 +2008,8 @@
"fast": 6 "fast": 6
}, },
"supports_max_reasoning_effort": true, "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true,
"supports_output_config": true
}, },
"claude-opus-4-7": { "claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -2028,7 +2044,8 @@
"us": 1.1, "us": 1.1,
"fast": 6 "fast": 6
}, },
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true,
"supports_output_config": true
}, },
"claude-opus-4-7-20260416": { "claude-opus-4-7-20260416": {
"cache_creation_input_token_cost": 0.00000625, "cache_creation_input_token_cost": 0.00000625,
@ -2063,7 +2080,8 @@
"us": 1.1, "us": 1.1,
"fast": 6 "fast": 6
}, },
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true,
"supports_output_config": true
}, },
"claude-sonnet-4-20250514": { "claude-sonnet-4-20250514": {
"deprecation_date": "2026-05-14", "deprecation_date": "2026-05-14",
@ -4564,6 +4582,7 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_output_config": true,
"supports_max_reasoning_effort": true, "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
@ -4592,6 +4611,7 @@
"supports_tool_choice": true, "supports_tool_choice": true,
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346, "tool_use_system_prompt_tokens": 346,
"supports_output_config": true,
"supports_max_reasoning_effort": true, "supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
@ -4705,6 +4725,7 @@
"search_context_size_low": 0.01, "search_context_size_low": 0.01,
"search_context_size_medium": 0.01 "search_context_size_medium": 0.01
}, },
"supports_output_config": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
"vertex_ai/claude-sonnet-4-5@20250929": { "vertex_ai/claude-sonnet-4-5@20250929": {
@ -4846,6 +4867,7 @@
"search_context_size_low": 0.01, "search_context_size_low": 0.01,
"search_context_size_medium": 0.01 "search_context_size_medium": 0.01
}, },
"supports_output_config": true,
"supports_minimal_reasoning_effort": true "supports_minimal_reasoning_effort": true
}, },
"bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": { "bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": {

View file

@ -1,27 +1,27 @@
{ {
"version": "0.0.46", "version": "0.0.47",
"sourceRef": "v0.0.46", "sourceRef": "v0.0.47",
"sourceRepository": "777genius/agent_teams_orchestrator", "sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai", "releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v2.1.1", "releaseTag": "v2.1.2",
"assets": { "assets": {
"darwin-arm64": { "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", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"darwin-x64": { "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", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"linux-x64": { "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", "archiveKind": "tar.gz",
"binaryName": "claude-multimodel" "binaryName": "claude-multimodel"
}, },
"win32-x64": { "win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.46.zip", "file": "agent-teams-runtime-win32-x64-v0.0.47.zip",
"archiveKind": "zip", "archiveKind": "zip",
"binaryName": "claude-multimodel.exe" "binaryName": "claude-multimodel.exe"
} }

View file

@ -92,9 +92,7 @@ function shouldKeepNodePtyPrebuild(entryName, platform, archLabel) {
if (platform === 'darwin' && archLabel === 'universal') { if (platform === 'darwin' && archLabel === 'universal') {
return ( return (
entryName === 'darwin-universal' || entryName === 'darwin-universal' || entryName === 'darwin-arm64' || entryName === 'darwin-x64'
entryName === 'darwin-arm64' ||
entryName === 'darwin-x64'
); );
} }
@ -154,11 +152,85 @@ async function pruneNodePtyArtifacts(appOutDir, platform, archLabel) {
removedPaths.push(absolutePath); 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; 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) { function findNodeModulesSequence(segments, sequence) {
for (let index = 0; index <= segments.length - sequence.length; index += 1) { for (let index = 0; index <= segments.length - sequence.length; index += 1) {
let matches = true; let matches = true;
@ -332,12 +404,7 @@ function parseElf(buffer) {
if (buffer.length < 20) { if (buffer.length < 20) {
return null; return null;
} }
if ( if (buffer[0] !== 0x7f || buffer[1] !== 0x45 || buffer[2] !== 0x4c || buffer[3] !== 0x46) {
buffer[0] !== 0x7f ||
buffer[1] !== 0x45 ||
buffer[2] !== 0x4c ||
buffer[3] !== 0x46
) {
return null; return null;
} }
@ -454,11 +521,7 @@ async function afterPack(context) {
const removedPaths = [ const removedPaths = [
...(await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch)), ...(await pruneNodePtyArtifacts(context.appOutDir, targetPlatform, targetArch)),
...(await pruneKnownIncompatibleNativeArtifacts( ...(await pruneKnownIncompatibleNativeArtifacts(context.appOutDir, targetPlatform, targetArch)),
context.appOutDir,
targetPlatform,
targetArch
)),
]; ];
const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch); const mismatches = await validateNativeBinaries(context.appOutDir, targetPlatform, targetArch);

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, isElectronMode } from '@renderer/api'; import { api, isElectronMode } from '@renderer/api';
import { scheduleStartupIdleTask } from '@renderer/utils/startupIdleTask';
import type { import type {
CodexAccountSnapshotDto, 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_RATE_LIMITS_REFRESH_MS = 10_000;
const CODEX_VISIBLE_STANDARD_REFRESH_MS = 20_000; const CODEX_VISIBLE_STANDARD_REFRESH_MS = 20_000;
const CODEX_HIDDEN_REFRESH_MS = 60_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 { function isDocumentVisible(): boolean {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
@ -41,6 +45,8 @@ function getRefreshIntervalMs(options: {
export function useCodexAccountSnapshot(options: { export function useCodexAccountSnapshot(options: {
enabled: boolean; enabled: boolean;
includeRateLimits?: boolean; includeRateLimits?: boolean;
initialRefreshDelayMs?: number;
initialRefreshMaxDelayMs?: number;
}): { }): {
snapshot: CodexAccountSnapshotDto | null; snapshot: CodexAccountSnapshotDto | null;
loading: boolean; loading: boolean;
@ -62,6 +68,11 @@ export function useCodexAccountSnapshot(options: {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [visible, setVisible] = useState(() => isDocumentVisible()); const [visible, setVisible] = useState(() => isDocumentVisible());
const lastUpdatedAtRef = useRef<number | null>(null); 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) => { const applySnapshot = useCallback((nextSnapshot: CodexAccountSnapshotDto) => {
lastUpdatedAtRef.current = Date.now(); lastUpdatedAtRef.current = Date.now();
@ -117,38 +128,85 @@ export function useCodexAccountSnapshot(options: {
return; return;
} }
setLoading(true); let active = true;
if (options.includeRateLimits) { let cancelInitialRefresh: (() => void) | null = null;
setRateLimitsLoading(true);
}
setError(null);
const initialSnapshotRequest = options.includeRateLimits const startInitialSnapshotRequest = (): void => {
? api.refreshCodexAccountSnapshot({ if (!active || lastUpdatedAtRef.current !== null) {
includeRateLimits: true, 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 if (initialRefreshDelayMs > 0) {
.then((nextSnapshot) => { if (typeof initialRefreshMaxDelayMs === 'number') {
applySnapshot(nextSnapshot); cancelInitialRefresh = scheduleStartupIdleTask(startInitialSnapshotRequest, {
}) minDelayMs: initialRefreshDelayMs,
.catch((nextError) => { maxDelayMs: initialRefreshMaxDelayMs,
setError(nextError instanceof Error ? nextError.message : 'Failed to load Codex account'); });
}) } else {
.finally(() => { const initialRefreshTimer = window.setTimeout(
setLoading(false); startInitialSnapshotRequest,
if (options.includeRateLimits) { initialRefreshDelayMs
setRateLimitsLoading(false); );
} cancelInitialRefresh = () => window.clearTimeout(initialRefreshTimer);
}); }
} else {
startInitialSnapshotRequest();
}
const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => { const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => {
applySnapshot(nextSnapshot); applySnapshot(nextSnapshot);
}); });
return unsubscribe; return () => {
}, [applySnapshot, electronMode, options.enabled, options.includeRateLimits]); active = false;
cancelInitialRefresh?.();
unsubscribe();
};
}, [
applySnapshot,
electronMode,
initialRefreshDelayMs,
initialRefreshMaxDelayMs,
options.enabled,
options.includeRateLimits,
]);
useEffect(() => { useEffect(() => {
if (!electronMode || !options.enabled || typeof document === 'undefined') { if (!electronMode || !options.enabled || typeof document === 'undefined') {
@ -167,6 +225,15 @@ export function useCodexAccountSnapshot(options: {
? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS ? CODEX_VISIBLE_RATE_LIMITS_REFRESH_MS
: CODEX_VISIBLE_STANDARD_REFRESH_MS; : CODEX_VISIBLE_STANDARD_REFRESH_MS;
if (
initialRefreshDelayMs > 0 &&
lastUpdatedAtRef.current === null &&
snapshot === null &&
!initialRefreshAttempted
) {
return;
}
if ( if (
lastUpdatedAtRef.current === null || lastUpdatedAtRef.current === null ||
Date.now() - lastUpdatedAtRef.current >= staleAfterMs Date.now() - lastUpdatedAtRef.current >= staleAfterMs
@ -182,12 +249,23 @@ export function useCodexAccountSnapshot(options: {
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}, [electronMode, options.enabled, options.includeRateLimits, refresh]); }, [
electronMode,
initialRefreshAttempted,
initialRefreshDelayMs,
options.enabled,
options.includeRateLimits,
refresh,
snapshot,
]);
useEffect(() => { useEffect(() => {
if (!electronMode || !options.enabled) { if (!electronMode || !options.enabled) {
return; return;
} }
if (initialRefreshDelayMs > 0 && snapshot === null && !initialRefreshAttempted) {
return;
}
const refreshIntervalMs = getRefreshIntervalMs({ const refreshIntervalMs = getRefreshIntervalMs({
loginStatus: snapshot?.login.status, loginStatus: snapshot?.login.status,
@ -206,9 +284,12 @@ export function useCodexAccountSnapshot(options: {
}; };
}, [ }, [
electronMode, electronMode,
initialRefreshAttempted,
initialRefreshDelayMs,
options.enabled, options.enabled,
options.includeRateLimits, options.includeRateLimits,
refresh, refresh,
snapshot,
snapshot?.login.status, snapshot?.login.status,
visible, visible,
]); ]);

View file

@ -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 { mergeCodexCliStatusWithSnapshot } from './mergeCodexCliStatusWithSnapshot';
export { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot'; export { mergeCodexProviderStatusWithSnapshot } from './mergeCodexProviderStatusWithSnapshot';
export { export {

View file

@ -30,6 +30,7 @@ import type { ChildProcessWithoutNullStreams } from 'child_process';
const PROBE_COMMAND_TIMEOUT_MS = 90_000; const PROBE_COMMAND_TIMEOUT_MS = 90_000;
const COMMAND_TIMEOUT_MS = PROBE_COMMAND_TIMEOUT_MS; 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_ERROR_DETAIL_LIMIT = 1_600;
const COMMAND_OUTPUT_PREVIEW_LIMIT = 1_200; const COMMAND_OUTPUT_PREVIEW_LIMIT = 1_200;
const ESCAPE_CHARACTER = String.fromCharCode(27); 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 }>( function runtimeProviderCommandOptions<T extends { env: NodeJS.ProcessEnv }>(
options: T, options: T,
projectPath: string | null projectPath: string | null
): T & { cwd?: string } { ): T & { cwd?: string; maxBuffer: number } {
return projectPath ? { ...options, cwd: projectPath } : options; const commandOptions = {
...options,
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
};
return projectPath ? { ...commandOptions, cwd: projectPath } : commandOptions;
} }
async function resolveCliEnv(): Promise<{ async function resolveCliEnv(): Promise<{

View file

@ -13,6 +13,7 @@ import {
SelectValue, SelectValue,
} from '@renderer/components/ui/select'; } from '@renderer/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { cn } from '@renderer/lib/utils';
import { import {
compareOpenCodeTeamModelRecommendations, compareOpenCodeTeamModelRecommendations,
getOpenCodeTeamModelRecommendation, getOpenCodeTeamModelRecommendation,
@ -819,7 +820,10 @@ const RuntimeProviderErrorAlert = ({
type="button" type="button"
size="sm" size="sm"
variant="ghost" 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'} title={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
aria-label={copied ? 'Diagnostics copied' : 'Copy diagnostics'} aria-label={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
onClick={(event) => { onClick={(event) => {

View file

@ -354,10 +354,18 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str
return 'The current review request is still waiting for explicit review pickup.'; return 'The current review request is still waiting for explicit review pickup.';
} }
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise<string | null> { async function resolveOpenCodeRuntimeBinaryForBridgeEnv(options?: {
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath(); includeShellEnv?: boolean;
}): Promise<string | null> {
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath({
includeShellEnv: options?.includeShellEnv,
});
if (resolvedBinaryPath) return resolvedBinaryPath; if (resolvedBinaryPath) return resolvedBinaryPath;
if (options?.includeShellEnv === false) {
return null;
}
try { try {
const status = await openCodeRuntimeInstallerService?.getStatus(); const status = await openCodeRuntimeInstallerService?.getStatus();
return status?.installed === true && status.binaryPath ? status.binaryPath : null; return status?.installed === true && status.binaryPath ? status.binaryPath : null;
@ -448,15 +456,18 @@ async function createOpenCodeRuntimeAdapterRegistry(
copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv); copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv);
} }
}; };
const ensureOpenCodeRuntimeBinaryEnv = async (targetEnv: NodeJS.ProcessEnv): Promise<void> => { const ensureOpenCodeRuntimeBinaryEnv = async (
targetEnv: NodeJS.ProcessEnv,
options: { includeShellEnv?: boolean } = {}
): Promise<void> => {
await ensureOpenCodeBridgeRuntimeBinaryEnv({ await ensureOpenCodeBridgeRuntimeBinaryEnv({
targetEnv, targetEnv,
bridgeEnv, bridgeEnv,
resolveVerifiedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv, resolveVerifiedOpenCodeRuntimeBinaryPath: () =>
resolveOpenCodeRuntimeBinaryForBridgeEnv({ includeShellEnv: options.includeShellEnv }),
onWarning: (message) => logger.warn(message), onWarning: (message) => logger.warn(message),
}); });
}; };
await ensureOpenCodeRuntimeBinaryEnv(bridgeEnv);
try { try {
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...'); reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
@ -500,7 +511,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...'); reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => { const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
const nextEnv = { ...bridgeEnv }; const nextEnv = { ...bridgeEnv };
await ensureOpenCodeRuntimeBinaryEnv(nextEnv); await ensureOpenCodeRuntimeBinaryEnv(nextEnv, { includeShellEnv: true });
if (!useHttpMcpBridge) { if (!useHttpMcpBridge) {
return nextEnv; return nextEnv;
} }
@ -917,6 +928,8 @@ const startupTimers = new Set<ReturnType<typeof setTimeout>>();
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000; const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
const STARTUP_RECOVERY_DELAY_MS = 10_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 STARTUP_RECOVERY_CONCURRENCY = 1;
const appStartupStartedAt = Date.now(); const appStartupStartedAt = Date.now();
let appStartupSteps: AppStartupStep[] = [ let appStartupSteps: AppStartupStep[] = [
@ -2456,10 +2469,12 @@ function runPostRendererStartupTasks(): void {
); );
scheduleStartupTask(() => { scheduleStartupTask(() => {
void teamProvisioningService.warmup();
teamDataService.startProcessHealthPolling(); teamDataService.startProcessHealthPolling();
void schedulerService?.start(); void schedulerService?.start();
}, 5000); }, STARTUP_BACKGROUND_SERVICE_DELAY_MS);
scheduleStartupTask(() => {
void teamProvisioningService.warmup();
}, STARTUP_CLI_WARMUP_DELAY_MS);
} }
function scheduleRendererRecovery(win: BrowserWindow): void { function scheduleRendererRecovery(win: BrowserWindow): void {

View file

@ -15,6 +15,7 @@ import {
CLI_INSTALLER_VERIFY_PROVIDER_MODELS, CLI_INSTALLER_VERIFY_PROVIDER_MODELS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
} from '@preload/constants/ipcChannels'; } from '@preload/constants/ipcChannels';
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
import { getErrorMessage } from '@shared/utils/errorHandling'; import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
@ -24,6 +25,8 @@ import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
import type { CliInstallerService } from '../services'; import type { CliInstallerService } from '../services';
import type { import type {
CliInstallationStatus, CliInstallationStatus,
CliInstallerGetStatusOptions,
CliInstallerProviderStatusMode,
CliProviderId, CliProviderId,
CliProviderStatus, CliProviderStatus,
IpcResult, IpcResult,
@ -33,9 +36,12 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:cliInstaller'); const logger = createLogger('IPC:cliInstaller');
let service: CliInstallerService; let service: CliInstallerService;
let statusInFlight: Promise<CliInstallationStatus> | null = null; const statusInFlight = new Map<CliInstallerProviderStatusMode, Promise<CliInstallationStatus>>();
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>(); 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; let statusCacheGeneration = 0;
const STATUS_CACHE_TTL_MS = 5_000; const STATUS_CACHE_TTL_MS = 5_000;
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']); 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. * Initializes CLI installer handlers with the service instance.
*/ */
@ -92,32 +148,36 @@ export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
// ============================================================================= // =============================================================================
async function handleGetStatus( async function handleGetStatus(
_event: IpcMainInvokeEvent _event: IpcMainInvokeEvent,
options?: CliInstallerGetStatusOptions
): Promise<IpcResult<CliInstallationStatus>> { ): Promise<IpcResult<CliInstallationStatus>> {
try { try {
const normalizedOptions = normalizeGetStatusOptions(options);
const cacheKey = normalizedOptions.providerStatusMode;
const latestSnapshot = service.getLatestStatusSnapshot(); const latestSnapshot = service.getLatestStatusSnapshot();
if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) { const cached = cachedStatus.get(cacheKey);
if (latestSnapshot) { if (cached && Date.now() - cached.at < STATUS_CACHE_TTL_MS) {
cachedStatus = { value: latestSnapshot, at: Date.now() }; if (latestSnapshot && canUseStatusForCacheKey(cacheKey, latestSnapshot)) {
cachedStatus.set(cacheKey, { value: latestSnapshot, at: Date.now() });
return { success: true, data: latestSnapshot }; 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 startedAt = Date.now();
const generation = statusCacheGeneration; const generation = statusCacheGeneration;
const request = service const request = service
.getStatus() .getStatus(normalizedOptions)
.then((status) => { .then((status) => {
if (generation === statusCacheGeneration) { if (generation === statusCacheGeneration && canUseStatusForCacheKey(cacheKey, status)) {
cachedStatus = { value: status, at: Date.now() }; cachedStatus.set(cacheKey, { value: status, at: Date.now() });
} }
return status; return status;
}) })
.catch((err) => { .catch((err) => {
if (generation === statusCacheGeneration) { if (generation === statusCacheGeneration) {
cachedStatus = null; cachedStatus.delete(cacheKey);
} }
throw err; throw err;
}) })
@ -126,14 +186,14 @@ async function handleGetStatus(
if (ms >= 2000) { if (ms >= 2000) {
logger.warn(`cliInstaller:getStatus slow ms=${ms}`); logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
} }
if (statusInFlight === request) { if (statusInFlight.get(cacheKey) === request) {
statusInFlight = null; statusInFlight.delete(cacheKey);
} }
}); });
statusInFlight = request; statusInFlight.set(cacheKey, request);
} }
const status = await statusInFlight; const status = await statusInFlight.get(cacheKey)!;
return { success: true, data: status }; return { success: true, data: status };
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
@ -143,42 +203,44 @@ async function handleGetStatus(
} }
function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void { function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void {
if (!cachedStatus || !providerStatus) { if (!providerStatus) {
return; return;
} }
if ( for (const [cacheKey, cached] of cachedStatus) {
cachedStatus.value.flavor === 'agent_teams_orchestrator' && if (
!isFrontendMultimodelProviderId(providerStatus.providerId) cached.value.flavor === 'agent_teams_orchestrator' &&
) { !isFrontendMultimodelProviderId(providerStatus.providerId)
return; ) {
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( async function handleGetProviderStatus(
@ -248,8 +310,8 @@ async function handleVerifyProviderModels(
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> { function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
statusCacheGeneration += 1; statusCacheGeneration += 1;
cachedStatus = null; cachedStatus.clear();
statusInFlight = null; statusInFlight.clear();
providerStatusInFlight.clear(); providerStatusInFlight.clear();
ClaudeBinaryResolver.clearCache(); ClaudeBinaryResolver.clearCache();
CodexBinaryResolver.clearCache(); CodexBinaryResolver.clearCache();

View file

@ -28,6 +28,7 @@ import {
getShellPreferredHome, getShellPreferredHome,
resolveInteractiveShellEnvBestEffort, resolveInteractiveShellEnvBestEffort,
} from '@main/utils/shellEnv'; } from '@main/utils/shellEnv';
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
import { getErrorMessage } from '@shared/utils/errorHandling'; import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
@ -49,7 +50,9 @@ import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor
import type { import type {
CliInstallationStatus, CliInstallationStatus,
CliInstallerGetStatusOptions,
CliInstallerProgress, CliInstallerProgress,
CliInstallerProviderStatusMode,
CliPlatform, CliPlatform,
CliProviderId, CliProviderId,
CliProviderModelAvailability, 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. */ /** NDJSON: strip C0 controls (except \\t \\n \\r) so logs stay valid text and tiny. */
function stripControlForDiag(s: string): string { 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'); return s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '\uFFFD');
} }
@ -868,8 +872,9 @@ export class CliInstallerService {
// Public: getStatus // Public: getStatus
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async getStatus(): Promise<CliInstallationStatus> { async getStatus(options: CliInstallerGetStatusOptions = {}): Promise<CliInstallationStatus> {
const statusStartedAt = Date.now(); const statusStartedAt = Date.now();
const providerStatusMode: CliInstallerProviderStatusMode = options.providerStatusMode ?? 'full';
const generation = ++this.statusGatherGeneration; const generation = ++this.statusGatherGeneration;
const result = this.createInitialStatus(); const result = this.createInitialStatus();
this.latestProviderSignatures.clear(); this.latestProviderSignatures.clear();
@ -882,7 +887,7 @@ export class CliInstallerService {
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
try { try {
await Promise.race([ await Promise.race([
this.gatherStatus(ref, runDiag, generation), this.gatherStatus(ref, runDiag, generation, providerStatusMode),
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
timer = setTimeout(() => { timer = setTimeout(() => {
logger.warn( logger.warn(
@ -1023,16 +1028,21 @@ export class CliInstallerService {
private async gatherStatus( private async gatherStatus(
ref: { current: CliInstallationStatus }, ref: { current: CliInstallationStatus },
diag: CliInstallerStatusRunDiag, diag: CliInstallerStatusRunDiag,
generation: number generation: number,
providerStatusMode: CliInstallerProviderStatusMode
): Promise<void> { ): Promise<void> {
resetGatherDiag(diag); resetGatherDiag(diag);
const shellEnvStartedAt = Date.now(); if (providerStatusMode === 'defer') {
await resolveInteractiveShellEnvBestEffort({ diag.shellEnvMs = 0;
timeoutMs: 1_500, } else {
fallbackEnv: process.env, const shellEnvStartedAt = Date.now();
background: false, await resolveInteractiveShellEnvBestEffort({
}); timeoutMs: 1_500,
diag.shellEnvMs = Date.now() - shellEnvStartedAt; fallbackEnv: process.env,
background: false,
});
diag.shellEnvMs = Date.now() - shellEnvStartedAt;
}
const r = ref.current; const r = ref.current;
const binaryResolveStartedAt = Date.now(); const binaryResolveStartedAt = Date.now();
@ -1048,6 +1058,14 @@ export class CliInstallerService {
r.installedVersion = versionProbe.version; r.installedVersion = versionProbe.version;
r.launchError = null; r.launchError = null;
r.authStatusChecking = true; 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.rememberHealthyStatus(r);
this.publishStatusSnapshotIfCurrent(r, generation); this.publishStatusSnapshotIfCurrent(r, generation);
@ -1186,6 +1204,28 @@ export class CliInstallerService {
result.authMethod = null; 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. * 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. * Wrapped in its own timeout to prevent slow auth from blocking the overall status.

View file

@ -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
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -212,8 +212,13 @@ async function probeFirstWorkingOpenCodeBinaryCandidate(
return { ok: false, firstFailure: nextFirstFailure }; return { ok: false, firstFailure: nextFirstFailure };
} }
interface OpenCodeRuntimeBinaryResolveOptions {
shellEnvTimeoutMs?: number;
includeShellEnv?: boolean;
}
async function probeFirstWorkingPathOpenCodeBinary( async function probeFirstWorkingPathOpenCodeBinary(
options: { shellEnvTimeoutMs?: number } = {} options: OpenCodeRuntimeBinaryResolveOptions = {}
): Promise<VerifiedOpenCodeBinaryProbe> { ): Promise<VerifiedOpenCodeBinaryProbe> {
const seenCandidates = new Set<string>(); const seenCandidates = new Set<string>();
let firstFailure: { binaryPath: string; error: string } | null = null; let firstFailure: { binaryPath: string; error: string } | null = null;
@ -230,6 +235,16 @@ async function probeFirstWorkingPathOpenCodeBinary(
} }
firstFailure = cachedProbe.firstFailure; firstFailure = cachedProbe.firstFailure;
if (options.includeShellEnv === false) {
return probeFirstWorkingOpenCodeBinaryCandidate(
collectPathOpenCodeBinaryCandidates([], {
includeFallbackPathEntries: true,
}),
seenCandidates,
firstFailure
);
}
const shellEnv = await resolveInteractiveShellEnvBestEffort({ const shellEnv = await resolveInteractiveShellEnvBestEffort({
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS, timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
fallbackEnv: process.env, fallbackEnv: process.env,
@ -254,14 +269,14 @@ async function probeFirstWorkingPathOpenCodeBinary(
} }
async function resolveVerifiedPathOpenCodeBinaryPath( async function resolveVerifiedPathOpenCodeBinaryPath(
options: { shellEnvTimeoutMs?: number } = {} options: OpenCodeRuntimeBinaryResolveOptions = {}
): Promise<string | null> { ): Promise<string | null> {
const result = await probeFirstWorkingPathOpenCodeBinary(options); const result = await probeFirstWorkingPathOpenCodeBinary(options);
return result.ok ? result.binaryPath : null; return result.ok ? result.binaryPath : null;
} }
export async function resolveVerifiedOpenCodeRuntimeBinaryPath( export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
options: { shellEnvTimeoutMs?: number } = {} options: OpenCodeRuntimeBinaryResolveOptions = {}
): Promise<string | null> { ): Promise<string | null> {
return ( return (
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ?? (await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??

View file

@ -22,6 +22,7 @@ const RECONCILE_INTERVAL_MS = 30_000;
// If a new team artifact should produce TeamChangeEvent, add it here too. // If a new team artifact should produce TeamChangeEvent, add it here too.
const TEAM_ROOT_FILES = new Set([ const TEAM_ROOT_FILES = new Set([
'config.json', 'config.json',
'kanban-state.json',
'processes.json', 'processes.json',
'sentMessages.json', 'sentMessages.json',
'team.meta.json', 'team.meta.json',
@ -62,7 +63,7 @@ export class TeamTaskWatchRegistry {
if (this.closed) { if (this.closed) {
return; return;
} }
await this.reconcileTargets(); await this.reconcileTargets({ rethrowErrors: true });
if (this.closed || this.reconcileTimer) { if (this.closed || this.reconcileTimer) {
return; return;
} }
@ -89,11 +90,11 @@ export class TeamTaskWatchRegistry {
this.targets.clear(); this.targets.clear();
this.targetKey = ''; this.targetKey = '';
if (watcher) { 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) { if (this.closed) {
return; return;
} }
@ -111,6 +112,9 @@ export class TeamTaskWatchRegistry {
await this.rebuildWatcher(targets, nextKey, addedTargets); await this.rebuildWatcher(targets, nextKey, addedTargets);
} }
} catch (error) { } catch (error) {
if (options.rethrowErrors) {
throw error;
}
if (!this.closed) { if (!this.closed) {
this.options.onError(error); this.options.onError(error);
} }
@ -135,7 +139,7 @@ export class TeamTaskWatchRegistry {
const previousWatcher = this.watcher; const previousWatcher = this.watcher;
this.watcher = null; this.watcher = null;
if (previousWatcher) { if (previousWatcher) {
await previousWatcher.close().catch(() => undefined); await this.closeWatcher(previousWatcher);
} }
if (this.closed || generation !== this.generation) { 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> { private async isDirectory(dirPath: string): Promise<boolean> {
try { try {
return (await fsp.stat(dirPath)).isDirectory(); return (await fsp.stat(dirPath)).isDirectory();

View file

@ -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({ await resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_500, timeoutMs: 1_500,
fallbackEnv: process.env, fallbackEnv: process.env,
@ -323,13 +336,15 @@ export class ClaudeBinaryResolver {
} }
if (flavor === 'agent_teams_orchestrator') { if (flavor === 'agent_teams_orchestrator') {
emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...'); if (!shouldTryBundledOrchestratorBeforeShell) {
const bundledBinary = await resolveBundledOrchestratorBinary(); emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
if (bundledBinary) { const bundledBinary = await resolveBundledOrchestratorBinary();
cachedPath = bundledBinary; if (bundledBinary) {
cacheVerifiedAt = Date.now(); cachedPath = bundledBinary;
emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...'); cacheVerifiedAt = Date.now();
return cachedPath; emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
return cachedPath;
}
} }
// Keep agent_teams_orchestrator resolution generic. Dev flows should // Keep agent_teams_orchestrator resolution generic. Dev flows should

View file

@ -26,6 +26,9 @@ const MAX_CLI_LOG_CHARS = 256_000;
const MAX_TRACE_CHARS = 128_000; const MAX_TRACE_CHARS = 128_000;
const MAX_COPIED_FILE_BYTES = 256 * 1024; const MAX_COPIED_FILE_BYTES = 256 * 1024;
const MAX_DIAGNOSTICS_COPY_FILE_BYTES = 128 * 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>; type JsonRecord = Record<string, unknown>;
@ -63,6 +66,7 @@ export type LaunchFailureArtifactClassificationCode =
| 'stdin_missing' | 'stdin_missing'
| 'provider_quota' | 'provider_quota'
| 'provider_auth' | 'provider_auth'
| 'process_readiness_timeout'
| 'model_no_bootstrap' | 'model_no_bootstrap'
| 'process_exited' | 'process_exited'
| 'opencode_protocol' | 'opencode_protocol'
@ -289,6 +293,12 @@ export function classifyLaunchFailureArtifact(
pattern: pattern:
/401 unauthorized|not_logged_in|login required|auth(?:entication)? failed|api key.*(?:missing|invalid)|token refresh failed/i, /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', code: 'opencode_protocol',
confidence: 0.84, 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; 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( function resolveArtifactManifestPath(
teamDir: string, teamDir: string,
latestJson: JsonRecord | null, 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( files.push(
await readDiagnosticsCopyFile( await readDiagnosticsCopyFile(
'bootstrap-journal.jsonl', '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 bootstrapStatePath = getTeamBootstrapStatePath(teamName);
const teamDir = path.dirname(bootstrapStatePath); const teamDir = path.dirname(bootstrapStatePath);
return [ const launchFiles: CopiedArtifactFile[] = [
{ {
sourcePath: getTeamLaunchStatePath(teamName), sourcePath: getTeamLaunchStatePath(teamName),
artifactName: 'launch-state.json', artifactName: 'launch-state.json',
@ -584,6 +637,7 @@ function getKnownLaunchArtifactSourceFiles(teamName: string): CopiedArtifactFile
artifactName: 'bootstrap-lock-metadata.json', artifactName: 'bootstrap-lock-metadata.json',
}, },
]; ];
return [...launchFiles, ...(await getRuntimeLaunchArtifactSourceFiles(teamDir))];
} }
async function writeArtifactTextFile( async function writeArtifactTextFile(
@ -593,6 +647,8 @@ async function writeArtifactTextFile(
files: string[] files: string[]
): Promise<void> { ): Promise<void> {
const targetPath = path.join(directory, artifactName); 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`); await atomicWriteAsync(targetPath, `${redactLaunchFailureArtifactText(rawText).trimEnd()}\n`);
files.push(artifactName); 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); const read = await readBoundedTextFile(source.sourcePath);
if (read.text !== undefined) { if (read.text !== undefined) {
await writeArtifactTextFile(directory, source.artifactName, read.text, files); await writeArtifactTextFile(directory, source.artifactName, read.text, files);

View file

@ -47,6 +47,9 @@ const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const; const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000; const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
const ELECTRON_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). * Stale configs older than this are removed on startup (best-effort).
* 7 days is intentionally long: respawnAfterAuthFailure() reuses saved * 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>; type McpServerConfig = Record<string, unknown>;
interface NodeRuntimeProbeMetadata {
path: string;
version: string;
}
const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'project', 'local']; const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'project', 'local'];
function isPackagedApp(): boolean { function isPackagedApp(): boolean {
@ -284,6 +292,56 @@ function mergePathValues(...values: (string | undefined)[]): string | undefined
return merged.length > 0 ? merged.join(path.delimiter) : 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 { function isWriteMcpConfigOptions(value: unknown): value is WriteMcpConfigOptions {
return ( return (
value !== null && value !== null &&
@ -310,16 +368,14 @@ async function probeNodeRuntimePath(
let lastError: unknown = null; let lastError: unknown = null;
for (const command of getNodeRuntimeCommandCandidates()) { for (const command of getNodeRuntimeCommandCandidates()) {
try { 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', encoding: 'utf-8',
timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS, timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS,
env, env,
}); });
const resolved = stdout.trim(); const metadata = parseNodeRuntimeProbeMetadata(stdout, command);
if (!resolved) { assertSupportedMcpNodeRuntime(command, metadata);
throw new Error(`${command} did not report process.execPath`); return { ok: true, path: metadata.path };
}
return { ok: true, path: resolved };
} catch (error) { } catch (error) {
lastError = error; lastError = error;
} }
@ -386,7 +442,9 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
if (_resolvedNodePath) return _resolvedNodePath; if (_resolvedNodePath) return _resolvedNodePath;
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...'); 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); const shellProbe = await probeShellNodeRuntimePath(options);
if (shellProbe.ok) { if (shellProbe.ok) {
_resolvedNodePath = shellProbe.path; _resolvedNodePath = shellProbe.path;
@ -402,18 +460,28 @@ async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<s
return _resolvedNodePath; return _resolvedNodePath;
} }
const shellProbe = await probeShellNodeRuntimePath(options); if (!preferShellNodeProbe) {
if (shellProbe.ok) { emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
_resolvedNodePath = shellProbe.path; const shellProbe = await probeShellNodeRuntimePath(options);
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...'); if (shellProbe.ok) {
return _resolvedNodePath; _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.'); emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
throw new Error( 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: ${ `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(
shellProbe.error ? stringifyError(shellProbe.error) : stringifyError(fastProbe.error) fastProbe.error
}` )}`
); );
} }

View file

@ -273,6 +273,7 @@ import type {
ClaudeRootFolderSelection, ClaudeRootFolderSelection,
ClaudeRootInfo, ClaudeRootInfo,
CliInstallationStatus, CliInstallationStatus,
CliInstallerGetStatusOptions,
CliInstallerProgress, CliInstallerProgress,
CliProviderId, CliProviderId,
ConflictCheckResult, ConflictCheckResult,
@ -1554,8 +1555,8 @@ const electronAPI: ElectronAPI = {
// ===== CLI Installer API ===== // ===== CLI Installer API =====
cliInstaller: { cliInstaller: {
getStatus: async (): Promise<CliInstallationStatus> => { getStatus: async (options?: CliInstallerGetStatusOptions): Promise<CliInstallationStatus> => {
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS); return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS, options);
}, },
getProviderStatus: async (providerId: CliProviderId) => { getProviderStatus: async (providerId: CliProviderId) => {
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId); return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);

View file

@ -16,6 +16,7 @@ declare global {
interface Window { interface Window {
__claudeTeamsSplashEnhancedStartedAt?: number; __claudeTeamsSplashEnhancedStartedAt?: number;
__claudeTeamsSplashScene?: SplashSceneHandle; __claudeTeamsSplashScene?: SplashSceneHandle;
__claudeTeamsSplashEnhancedDisabled?: boolean;
__claudeTeamsSplashStartedAt?: number; __claudeTeamsSplashStartedAt?: number;
} }
} }
@ -38,7 +39,11 @@ export const App = (): React.JSX.Element => {
const splash = document.getElementById('splash'); const splash = document.getElementById('splash');
if (splash) { if (splash) {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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 startedAt = window.__claudeTeamsSplashStartedAt ?? performance.now();
const enhancedStartedAt = window.__claudeTeamsSplashEnhancedStartedAt ?? performance.now(); const enhancedStartedAt = window.__claudeTeamsSplashEnhancedStartedAt ?? performance.now();
const elapsed = performance.now() - startedAt; const elapsed = performance.now() - startedAt;
@ -62,6 +67,7 @@ export const App = (): React.JSX.Element => {
scene.stop(); scene.stop();
window.__claudeTeamsSplashScene = undefined; window.__claudeTeamsSplashScene = undefined;
window.__claudeTeamsSplashEnhancedStartedAt = undefined; window.__claudeTeamsSplashEnhancedStartedAt = undefined;
window.__claudeTeamsSplashEnhancedDisabled = undefined;
splash.remove(); splash.remove();
}, fadeDuration); }, fadeDuration);
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 992 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 68 KiB

View file

@ -1,85 +1,17 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo } from 'react';
import { import {
CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
mergeCodexCliStatusWithSnapshot, mergeCodexCliStatusWithSnapshot,
useCodexAccountSnapshot, useCodexAccountSnapshot,
} from '@features/codex-account/renderer'; } from '@features/codex-account/renderer';
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; 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 { useShallow } from 'zustand/react/shallow';
import { ProviderBrandLogo } from './ProviderBrandLogo'; import { ProviderActivityStatusStrip } from './ProviderActivityStatusStrip';
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);
}
export const GlobalProviderStatusHeader = (): React.JSX.Element | null => { export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
const isElectron = useMemo(() => isElectronMode(), []); const isElectron = useMemo(() => isElectronMode(), []);
@ -106,7 +38,6 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
}; };
}) })
); );
const [cycleProviderIds, setCycleProviderIds] = useState<CliProviderId[]>([]);
const loadingCliStatus = useMemo( const loadingCliStatus = useMemo(
() => () =>
@ -123,6 +54,8 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
loadingCliStatus?.flavor === 'agent_teams_orchestrator' && loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
includeRateLimits: false, includeRateLimits: false,
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
initialRefreshMaxDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
}); });
const effectiveCliStatus = useMemo( const effectiveCliStatus = useMemo(
@ -134,177 +67,19 @@ export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) && Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
!codexAccount.snapshot; !codexAccount.snapshot;
const sourceProviderMap = useMemo( if (isDashboardFocused) {
() =>
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
) {
return null; return null;
} }
return ( return (
<div <ProviderActivityStatusStrip
className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2" cliStatus={effectiveCliStatus}
style={{ sourceCliStatus={loadingCliStatus}
backgroundColor: 'var(--color-surface-sidebar)', cliStatusLoading={cliStatusLoading}
borderColor: 'var(--color-border)', cliProviderStatusLoading={cliProviderStatusLoading}
}} multimodelEnabled={multimodelEnabled}
> codexSnapshotPending={codexSnapshotPending}
<span className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-sidebar)] px-4 py-2"
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>
); );
}; };

View 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>
);
};

View file

@ -7,9 +7,11 @@
* Only rendered in Electron mode. * Only rendered in Electron mode.
*/ */
import { useCallback, useEffect, useMemo, useState } from 'react'; import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { import {
CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
mergeCodexProviderStatusWithSnapshot, mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot, useCodexAccountSnapshot,
} from '@features/codex-account/renderer'; } from '@features/codex-account/renderer';
@ -31,16 +33,14 @@ import {
isConnectionManagedRuntimeProvider, isConnectionManagedRuntimeProvider,
isOpenCodeCatalogHydrating, isOpenCodeCatalogHydrating,
shouldShowProviderConnectAction, shouldShowProviderConnectAction,
shouldShowProviderStatusSkeleton,
} from '@renderer/components/runtime/providerConnectionUi'; } from '@renderer/components/runtime/providerConnectionUi';
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { import {
getProviderTerminalCommand, getProviderTerminalCommand,
getProviderTerminalLogoutCommand, getProviderTerminalLogoutCommand,
} from '@renderer/components/runtime/providerTerminalCommands'; } 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 { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { import {
loadDashboardCliStatusBannerCollapsed, loadDashboardCliStatusBannerCollapsed,
@ -55,6 +55,7 @@ import { resolveProjectPathById } from '@renderer/utils/projectLookup';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName'; import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog'; import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog';
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
import { import {
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
@ -112,6 +113,22 @@ const ATLAS_CLOUD_CODING_PLAN_URL = 'https://www.atlascloud.ai/console/coding-pl
const ATLAS_CLOUD_DESCRIPTION = 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."; "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 = ({ const DashboardRateLimitChips = ({
providerId, providerId,
items, 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( function isCodexSnapshotPending(
provider: CliProviderStatus, provider: CliProviderStatus,
codexSnapshotPending: boolean codexSnapshotPending: boolean
@ -489,6 +496,14 @@ function formatRuntimeLabel(
: runtimeLabel; : runtimeLabel;
} }
function isPendingMultimodelProviderStatus(provider: CliProviderStatus): boolean {
return (
!provider.authenticated &&
(provider.statusMessage === 'Checking...' ||
provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE)
);
}
function formatRuntimeAuthSummary( function formatRuntimeAuthSummary(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>, cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
visibleProviders: readonly CliProviderStatus[] visibleProviders: readonly CliProviderStatus[]
@ -498,11 +513,7 @@ function formatRuntimeAuthSummary(
return null; return null;
} }
if ( if (visibleProviders.every(isPendingMultimodelProviderStatus)) {
visibleProviders.every(
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
)
) {
return 'Checking providers...'; return 'Checking providers...';
} }
const denominator = visibleProviders.length; const denominator = visibleProviders.length;
@ -529,9 +540,7 @@ function isCheckingMultimodelStatus(
return ( return (
isMultimodelRuntimeStatus(cliStatus) && isMultimodelRuntimeStatus(cliStatus) &&
visibleProviders.length > 0 && visibleProviders.length > 0 &&
visibleProviders.every( visibleProviders.every(isPendingMultimodelProviderStatus)
(provider) => provider.statusMessage === 'Checking...' && !provider.authenticated
)
); );
} }
@ -955,7 +964,7 @@ const InstalledBanner = ({
provider provider
); );
const showSkeleton = const showSkeleton =
isProviderCardLoading(provider, providerLoading) || shouldShowProviderStatusSkeleton(provider, providerLoading) ||
isCodexSnapshotPending(provider, codexSnapshotPending) || isCodexSnapshotPending(provider, codexSnapshotPending) ||
maskNegativeBootstrapState; maskNegativeBootstrapState;
const anthropicRateLimitsLoading = const anthropicRateLimitsLoading =
@ -1358,6 +1367,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
loadingCliStatus?.flavor === 'agent_teams_orchestrator' && loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')), Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
includeRateLimits: true, includeRateLimits: true,
initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
initialRefreshMaxDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
}); });
const visibleCliProviders = useMemo( const visibleCliProviders = useMemo(
() => () =>
@ -1653,50 +1664,58 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const installedAuxiliaryUi = const installedAuxiliaryUi =
renderCliStatus !== null ? ( renderCliStatus !== null ? (
<> <>
<ProviderRuntimeSettingsDialog {manageDialogOpen && (
open={manageDialogOpen} <Suspense fallback={null}>
onOpenChange={handleManageDialogOpenChange} <ProviderRuntimeSettingsDialog
providers={visibleCliProviders} open={manageDialogOpen}
projectPath={selectedProjectPath} onOpenChange={handleManageDialogOpenChange}
initialProviderId={ providers={visibleCliProviders}
visibleCliProviders.some((provider) => provider.providerId === manageProviderId) projectPath={selectedProjectPath}
? manageProviderId initialProviderId={
: (visibleCliProviders[0]?.providerId ?? 'anthropic') visibleCliProviders.some((provider) => provider.providerId === manageProviderId)
} ? manageProviderId
initialRuntimeProviderId={manageRuntimeProviderId} : (visibleCliProviders[0]?.providerId ?? 'anthropic')
initialRuntimeProviderAction={manageRuntimeProviderId ? 'connect' : null} }
providerStatusLoading={cliProviderStatusLoading} initialRuntimeProviderId={manageRuntimeProviderId}
disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath} initialRuntimeProviderAction={manageRuntimeProviderId ? 'connect' : null}
codexRuntimeStatus={codexRuntimeStatus} providerStatusLoading={cliProviderStatusLoading}
codexRuntimeStatusLoading={codexRuntimeStatusLoading} disabled={isBusy || cliStatusLoading || !renderCliStatus.binaryPath}
onInstallCodexRuntime={() => installCodexRuntime()} codexRuntimeStatus={codexRuntimeStatus}
onSelectBackend={handleProviderBackendChange} codexRuntimeStatusLoading={codexRuntimeStatusLoading}
onRefreshProvider={handleProviderRefresh} onInstallCodexRuntime={() => installCodexRuntime()}
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })} onSelectBackend={handleProviderBackendChange}
/> onRefreshProvider={handleProviderRefresh}
onRequestLogin={(providerId) => setProviderTerminal({ providerId, action: 'login' })}
/>
</Suspense>
)}
{providerTerminal && renderCliStatus.binaryPath && ( {providerTerminal && renderCliStatus.binaryPath && (
<TerminalModal <Suspense fallback={null}>
title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} ${ <TerminalModal
providerTerminal.action === 'login' ? 'Login' : 'Logout' title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} ${
}: ${getProviderLabel(providerTerminal.providerId)}`} providerTerminal.action === 'login' ? 'Login' : 'Logout'
command={renderCliStatus.binaryPath} }: ${getProviderLabel(providerTerminal.providerId)}`}
args={providerTerminalCommand?.args} command={renderCliStatus.binaryPath}
env={providerTerminalCommand?.env} args={providerTerminalCommand?.args}
onClose={() => { env={providerTerminalCommand?.env}
setProviderTerminal(null); onClose={() => {
recheckAuthState(); setProviderTerminal(null);
}} recheckAuthState();
onExit={() => { }}
recheckAuthState(); onExit={() => {
}} recheckAuthState();
autoCloseOnSuccessMs={3000} }}
successMessage={ autoCloseOnSuccessMs={3000}
providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out' successMessage={
} providerTerminal.action === 'login'
failureMessage={ ? 'Authentication updated'
providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed' : 'Provider logged out'
} }
/> failureMessage={
providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
}
/>
</Suspense>
)} )}
</> </>
) : null; ) : null;
@ -1875,7 +1894,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
Installing {runtimeDisplayName}... Installing {runtimeDisplayName}...
</span> </span>
</div> </div>
<TerminalLogPanel chunks={installerRawChunks} /> <Suspense fallback={null}>
<TerminalLogPanel chunks={installerRawChunks} />
</Suspense>
</div> </div>
); );
} }
@ -2248,45 +2269,47 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
</div> </div>
{installedAuxiliaryUi} {installedAuxiliaryUi}
{showLoginTerminal && renderCliStatus.binaryPath && ( {showLoginTerminal && renderCliStatus.binaryPath && (
<TerminalModal <Suspense fallback={null}>
title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} Login`} <TerminalModal
command={renderCliStatus.binaryPath} title={`${getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled)} Login`}
args={['auth', 'login']} command={renderCliStatus.binaryPath}
onClose={() => { args={['auth', 'login']}
setShowLoginTerminal(false); onClose={() => {
setIsVerifyingAuth(true); setShowLoginTerminal(false);
void (async () => { setIsVerifyingAuth(true);
try { void (async () => {
await invalidateCliStatus(); try {
if (multimodelEnabled) { await invalidateCliStatus();
await bootstrapCliStatus({ multimodelEnabled: true }); if (multimodelEnabled) {
} else { await bootstrapCliStatus({ multimodelEnabled: true });
await fetchCliStatus(); } else {
await fetchCliStatus();
}
} finally {
setIsVerifyingAuth(false);
} }
} finally { })();
setIsVerifyingAuth(false); }}
} onExit={() => {
})(); setIsVerifyingAuth(true);
}} void (async () => {
onExit={() => { try {
setIsVerifyingAuth(true); await invalidateCliStatus();
void (async () => { if (multimodelEnabled) {
try { await bootstrapCliStatus({ multimodelEnabled: true });
await invalidateCliStatus(); } else {
if (multimodelEnabled) { await fetchCliStatus();
await bootstrapCliStatus({ multimodelEnabled: true }); }
} else { } finally {
await fetchCliStatus(); setIsVerifyingAuth(false);
} }
} finally { })();
setIsVerifyingAuth(false); }}
} autoCloseOnSuccessMs={4000}
})(); successMessage="Login complete"
}} failureMessage="Login failed"
autoCloseOnSuccessMs={4000} />
successMessage="Login complete" </Suspense>
failureMessage="Login failed"
/>
)} )}
</> </>
); );

View file

@ -258,12 +258,21 @@ export const ExtensionStoreView = (): React.JSX.Element => {
}, [fetchPluginCatalog, projectPath]); }, [fetchPluginCatalog, projectPath]);
useEffect(() => { useEffect(() => {
const cliStatusMatchesCurrentMode =
cliStatus &&
(multimodelEnabled
? cliStatus.flavor === 'agent_teams_orchestrator'
: cliStatus.flavor !== 'agent_teams_orchestrator');
if (cliStatusLoading || cliStatusMatchesCurrentMode) {
return;
}
void refreshCliStatusForCurrentMode({ void refreshCliStatusForCurrentMode({
multimodelEnabled, multimodelEnabled,
providerStatusMode: 'defer',
bootstrapCliStatus, bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
}); });
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]); }, [bootstrapCliStatus, cliStatus, cliStatusLoading, fetchCliStatus, multimodelEnabled]);
// Fetch MCP installed state on mount // Fetch MCP installed state on mount
useEffect(() => { useEffect(() => {
@ -512,6 +521,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
effectiveCliStatus, effectiveCliStatus,
effectiveCliStatusLoading, effectiveCliStatusLoading,
openDashboard, openDashboard,
runtimeDisplayName,
]); ]);
// Browser mode guard // Browser mode guard
@ -586,7 +596,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
{tabState.activeSubTab === 'mcp-servers' && ( {tabState.activeSubTab === 'mcp-servers' && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span tabIndex={mcpMutationDisableReason ? 0 : -1}> <span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View file

@ -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);
});
});

View file

@ -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>
);
};

View 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');
});
});

View file

@ -3,27 +3,135 @@
* Uses CSS display-toggle to keep all tabs mounted (preserving state). * 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 { TabUIProvider } from '@renderer/contexts/TabUIContext';
import { DashboardView } from '../dashboard/DashboardView'; 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 { 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 { interface PaneContentProps {
pane: Pane; pane: Pane;
isPaneFocused: boolean; 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 => { export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JSX.Element => {
const activeTabId = pane.activeTabId; const activeTabId = pane.activeTabId;
@ -41,46 +149,7 @@ export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JS
{pane.tabs.map((tab) => { {pane.tabs.map((tab) => {
const isActive = tab.id === activeTabId; const isActive = tab.id === activeTabId;
return ( return (
<div <PaneTabSlot key={tab.id} tab={tab} isActive={isActive} isPaneFocused={isPaneFocused} />
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>
); );
})} })}
</div> </div>

View 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);
});
});

View file

@ -8,14 +8,13 @@
* - Collapsible: Cmd+B to toggle (Notion-style) * - 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 { useStore } from '@renderer/store';
import { formatShortcut } from '@renderer/utils/stringUtils'; import { formatShortcut } from '@renderer/utils/stringUtils';
import { PanelLeft } from 'lucide-react'; import { PanelLeft } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { DateGroupedSessions } from '../sidebar/DateGroupedSessions';
import { GlobalTaskList } from '../sidebar/GlobalTaskList'; import { GlobalTaskList } from '../sidebar/GlobalTaskList';
import { defaultTaskFiltersState } from '../sidebar/taskFiltersState'; import { defaultTaskFiltersState } from '../sidebar/taskFiltersState';
@ -23,6 +22,12 @@ import type { TaskFiltersState } from '../sidebar/taskFiltersState';
type SidebarTab = 'tasks' | 'sessions'; type SidebarTab = 'tasks' | 'sessions';
const DateGroupedSessions = lazy(() =>
import('../sidebar/DateGroupedSessions').then((module) => ({
default: module.DateGroupedSessions,
}))
);
const MIN_WIDTH = 200; const MIN_WIDTH = 200;
const MAX_WIDTH = 500; const MAX_WIDTH = 500;
const DEFAULT_WIDTH = 280; const DEFAULT_WIDTH = 280;
@ -37,6 +42,7 @@ export const Sidebar = (): React.JSX.Element => {
const [width, setWidth] = useState(DEFAULT_WIDTH); const [width, setWidth] = useState(DEFAULT_WIDTH);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [sidebarTab, setSidebarTab] = useState<SidebarTab>('tasks'); const [sidebarTab, setSidebarTab] = useState<SidebarTab>('tasks');
const [hasOpenedSessionsTab, setHasOpenedSessionsTab] = useState(false);
const [taskFilters, setTaskFilters] = useState<TaskFiltersState>(defaultTaskFiltersState); const [taskFilters, setTaskFilters] = useState<TaskFiltersState>(defaultTaskFiltersState);
const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false); const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false);
const [isCollapseHovered, setIsCollapseHovered] = useState(false); const [isCollapseHovered, setIsCollapseHovered] = useState(false);
@ -77,6 +83,12 @@ export const Sidebar = (): React.JSX.Element => {
}; };
}, [isResizing, handleMouseMove, handleMouseUp]); }, [isResizing, handleMouseMove, handleMouseUp]);
useEffect(() => {
if (sidebarTab === 'sessions') {
setHasOpenedSessionsTab(true);
}
}, [sidebarTab]);
const handleResizeStart = (e: React.MouseEvent): void => { const handleResizeStart = (e: React.MouseEvent): void => {
e.preventDefault(); e.preventDefault();
setIsResizing(true); setIsResizing(true);
@ -195,7 +207,11 @@ export const Sidebar = (): React.JSX.Element => {
hidden={sidebarTab !== 'sessions'} hidden={sidebarTab !== 'sessions'}
className="min-w-0 flex-1 overflow-hidden" className="min-w-0 flex-1 overflow-hidden"
> >
<DateGroupedSessions /> {hasOpenedSessionsTab && (
<Suspense fallback={null}>
<DateGroupedSessions />
</Suspense>
)}
</div> </div>
</div> </div>

View file

@ -34,9 +34,9 @@ import { UpdateBanner } from '../common/UpdateBanner';
import { UpdateDialog } from '../common/UpdateDialog'; import { UpdateDialog } from '../common/UpdateDialog';
import { WorkspaceIndicator } from '../common/WorkspaceIndicator'; import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
import { CommandPalette } from '../search/CommandPalette'; import { CommandPalette } from '../search/CommandPalette';
import { GlobalTaskDetailDialog } from '../team/dialogs/GlobalTaskDetailDialog';
import { CustomTitleBar } from './CustomTitleBar'; import { CustomTitleBar } from './CustomTitleBar';
import { GlobalTaskDetailDialogSlot } from './GlobalTaskDetailDialogSlot';
import { PaneContainer } from './PaneContainer'; import { PaneContainer } from './PaneContainer';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { DragOverlayTab } from './SortableTab'; import { DragOverlayTab } from './SortableTab';
@ -186,7 +186,7 @@ export const TabbedLayout = (): React.JSX.Element => {
{activeTab ? <DragOverlayTab tab={activeTab} /> : null} {activeTab ? <DragOverlayTab tab={activeTab} /> : null}
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
<GlobalTaskDetailDialog /> <GlobalTaskDetailDialogSlot />
<UpdateDialog /> <UpdateDialog />
<WorkspaceIndicator /> <WorkspaceIndicator />
</div> </div>

View file

@ -1,3 +1,5 @@
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types'; import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types';
const CODEX_NATIVE_LABEL = 'Codex native'; 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 { export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean {
return provider.providerId === 'codex'; return provider.providerId === 'codex';
} }

View file

@ -24,6 +24,7 @@ import {
isConnectionManagedRuntimeProvider, isConnectionManagedRuntimeProvider,
isOpenCodeCatalogHydrating, isOpenCodeCatalogHydrating,
shouldShowProviderConnectAction, shouldShowProviderConnectAction,
shouldShowProviderStatusSkeleton,
} from '@renderer/components/runtime/providerConnectionUi'; } from '@renderer/components/runtime/providerConnectionUi';
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector'; 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( function isCodexSnapshotPending(
provider: CliProviderStatus, provider: CliProviderStatus,
codexSnapshotPending: boolean codexSnapshotPending: boolean
@ -474,7 +465,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
const providerLoading = const providerLoading =
cliProviderStatusLoading[provider.providerId] === true; cliProviderStatusLoading[provider.providerId] === true;
const showSkeleton = const showSkeleton =
isProviderCardLoading(provider, providerLoading) || shouldShowProviderStatusSkeleton(provider, providerLoading) ||
isCodexSnapshotPending(provider, codexSnapshotPending); isCodexSnapshotPending(provider, codexSnapshotPending);
const runtimeSummary = isConnectionManagedRuntimeProvider(provider) const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
? getProviderCurrentRuntimeSummary(provider) ? getProviderCurrentRuntimeSummary(provider)

View file

@ -274,24 +274,26 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
// Loading guards in the store actions prevent duplicate IPC calls // Loading guards in the store actions prevent duplicate IPC calls
// when the centralized init chain has already started a fetch. // when the centralized init chain has already started a fetch.
const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading); const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading);
const repositoryGroupsInitialized = useStore((s) => s.repositoryGroupsInitialized);
const repositoryGroupsError = useStore((s) => s.repositoryGroupsError); const repositoryGroupsError = useStore((s) => s.repositoryGroupsError);
const projectsLoading = useStore((s) => s.projectsLoading); const projectsLoading = useStore((s) => s.projectsLoading);
const projectsInitialized = useStore((s) => s.projectsInitialized);
const projectsError = useStore((s) => s.projectsError); const projectsError = useStore((s) => s.projectsError);
useEffect(() => { useEffect(() => {
if ( if (
viewMode === 'grouped' && viewMode === 'grouped' &&
repositoryGroups.length === 0 && !repositoryGroupsInitialized &&
!repositoryGroupsLoading && !repositoryGroupsLoading &&
!repositoryGroupsError !repositoryGroupsError
) { ) {
void fetchRepositoryGroups(); void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading && !projectsError) { } else if (viewMode === 'flat' && !projectsInitialized && !projectsLoading && !projectsError) {
void fetchProjects(); void fetchProjects();
} }
}, [ }, [
viewMode, viewMode,
repositoryGroups.length, repositoryGroupsInitialized,
projects.length, projectsInitialized,
repositoryGroupsLoading, repositoryGroupsLoading,
repositoryGroupsError, repositoryGroupsError,
projectsLoading, projectsLoading,

View file

@ -201,10 +201,18 @@ export const GlobalTaskList = memo(function GlobalTaskList({
globalTasksLoading, globalTasksLoading,
globalTasksInitialized, globalTasksInitialized,
fetchAllTasks, fetchAllTasks,
fetchProjects,
fetchRepositoryGroups,
softDeleteTask, softDeleteTask,
projects, projects,
projectsLoading,
projectsInitialized,
projectsError,
viewMode, viewMode,
repositoryGroups, repositoryGroups,
repositoryGroupsLoading,
repositoryGroupsInitialized,
repositoryGroupsError,
teams, teams,
provisioningRuns, provisioningRuns,
currentProvisioningRunIdByTeam, currentProvisioningRunIdByTeam,
@ -215,10 +223,18 @@ export const GlobalTaskList = memo(function GlobalTaskList({
globalTasksLoading: s.globalTasksLoading, globalTasksLoading: s.globalTasksLoading,
globalTasksInitialized: s.globalTasksInitialized, globalTasksInitialized: s.globalTasksInitialized,
fetchAllTasks: s.fetchAllTasks, fetchAllTasks: s.fetchAllTasks,
fetchProjects: s.fetchProjects,
fetchRepositoryGroups: s.fetchRepositoryGroups,
softDeleteTask: s.softDeleteTask, softDeleteTask: s.softDeleteTask,
projects: s.projects, projects: s.projects,
projectsLoading: s.projectsLoading,
projectsInitialized: s.projectsInitialized,
projectsError: s.projectsError,
viewMode: s.viewMode, viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
repositoryGroupsLoading: s.repositoryGroupsLoading,
repositoryGroupsInitialized: s.repositoryGroupsInitialized,
repositoryGroupsError: s.repositoryGroupsError,
teams: s.teams, teams: s.teams,
provisioningRuns: s.provisioningRuns, provisioningRuns: s.provisioningRuns,
currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam, currentProvisioningRunIdByTeam: s.currentProvisioningRunIdByTeam,
@ -442,6 +458,29 @@ export const GlobalTaskList = memo(function GlobalTaskList({
} }
}, [fetchAllTasks, globalTasksLoading]); }, [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 // Build project combobox options from available projects/repos
const projectFilterOptions = useMemo((): ComboboxOption[] => { const projectFilterOptions = useMemo((): ComboboxOption[] => {
const items = const items =

View file

@ -71,6 +71,7 @@ const TEAM_MEMBER_COUNTS = [4, 3, 5] as const;
const TEAM_MEMBER_OFFSETS = [0, 4, 7] as const; const TEAM_MEMBER_OFFSETS = [0, 4, 7] as const;
const TEAM_LABELS = ['Marketing', 'Researchers', 'Coding'] as const; const TEAM_LABELS = ['Marketing', 'Researchers', 'Coding'] as const;
const MAX_DPR = 2; const MAX_DPR = 2;
const SPLASH_SCENE_FRAME_INTERVAL_MS = 1000 / 30;
const avatarCache = new Map<string, HTMLImageElement>(); const avatarCache = new Map<string, HTMLImageElement>();
const avatarLoading = new Map<string, Promise<HTMLImageElement | null>>(); const avatarLoading = new Map<string, Promise<HTMLImageElement | null>>();
@ -112,6 +113,7 @@ export function startSplashScene(
particles: [] as DepthParticle[], particles: [] as DepthParticle[],
running: true, running: true,
frameId: 0, frameId: 0,
lastRenderedAt: 0,
startedAt: performance.now(), startedAt: performance.now(),
}; };
@ -139,9 +141,16 @@ export function startSplashScene(
const render = (now: number): void => { const render = (now: number): void => {
if (!state.running) return; if (!state.running) return;
resize(); if (
const time = (now - state.startedAt) / 1000; reducedMotion ||
drawScene(ctx, state.width, state.height, time, state.particles, 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) { if (!reducedMotion) {
state.frameId = window.requestAnimationFrame(render); state.frameId = window.requestAnimationFrame(render);

View file

@ -16,6 +16,7 @@ import {
resolveCodexRuntimeSelection, resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer'; } from '@features/codex-runtime-profile/renderer';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
import { import {
buildMemberDraftColorMap, buildMemberDraftColorMap,
buildMemberDraftSuggestions, buildMemberDraftSuggestions,
@ -396,8 +397,12 @@ export const CreateTeamDialog = ({
const anthropicProviderFastModeDefault = useStore( const anthropicProviderFastModeDefault = useStore(
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false (s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
); );
const { cliStatus, cliStatusLoading } = useStore( const { cliStatus, cliStatusLoading, cliProviderStatusLoading } = useStore(
useShallow((s) => ({ cliStatus: s.cliStatus, cliStatusLoading: s.cliStatusLoading })) useShallow((s) => ({
cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
cliProviderStatusLoading: s.cliProviderStatusLoading,
}))
); );
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus); const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
const fetchCliStatus = useStore((s) => s.fetchCliStatus); const fetchCliStatus = useStore((s) => s.fetchCliStatus);
@ -419,6 +424,10 @@ export const CreateTeamDialog = ({
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot), () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
[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) ────────────────── // ── Persisted draft state (survives tab navigation) ──────────────────
const { const {
@ -2457,6 +2466,18 @@ export const CreateTeamDialog = ({
<DialogFooter className="pt-4 sm:justify-between"> <DialogFooter className="pt-4 sm:justify-between">
<div className="min-w-0"> <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 && {canCreate &&
launchTeam && launchTeam &&
(effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? ( (effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? (

View file

@ -16,6 +16,7 @@ import {
resolveCodexRuntimeSelection, resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer'; } from '@features/codex-runtime-profile/renderer';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox'; import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
import { import {
buildMemberDraftColorMap, buildMemberDraftColorMap,
@ -356,6 +357,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
); );
const cliStatus = useStore((s) => s.cliStatus); const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading); const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading);
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus); const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
const fetchCliStatus = useStore((s) => s.fetchCliStatus); const fetchCliStatus = useStore((s) => s.fetchCliStatus);
const isLaunchMode = props.mode === 'launch' || props.mode === 'relaunch'; const isLaunchMode = props.mode === 'launch' || props.mode === 'relaunch';
@ -377,6 +379,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
() => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot), () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
[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 isSchedule = props.mode === 'schedule';
const schedule = isSchedule ? (props.schedule ?? null) : null; const schedule = isSchedule ? (props.schedule ?? null) : null;
const isEditing = isSchedule && !!schedule; const isEditing = isSchedule && !!schedule;
@ -3040,6 +3046,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{/* Launch-only: CLI warm-up status */} {/* Launch-only: CLI warm-up status */}
{isLaunchMode ? ( {isLaunchMode ? (
<div className="min-w-0"> <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' ? ( {effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' ? (
<> <>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]"> <div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">

View file

@ -9,7 +9,12 @@ import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts'; 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(): { export function useCliInstaller(): {
cliStatus: CliInstallationStatus | null; cliStatus: CliInstallationStatus | null;
@ -37,7 +42,10 @@ export function useCliInstaller(): {
codexRuntimeStatus: CodexRuntimeStatus | null; codexRuntimeStatus: CodexRuntimeStatus | null;
codexRuntimeStatusLoading: boolean; codexRuntimeStatusLoading: boolean;
codexRuntimeError: string | null; codexRuntimeError: string | null;
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>; bootstrapCliStatus: (options?: {
multimodelEnabled?: boolean;
providerStatusMode?: CliInstallerProviderStatusMode;
}) => Promise<void>;
fetchCliStatus: () => Promise<void>; fetchCliStatus: () => Promise<void>;
fetchCliProviderStatus: ( fetchCliProviderStatus: (
providerId: CliProviderId, providerId: CliProviderId,

View file

@ -561,6 +561,7 @@
var TEAM_MEMBER_OFFSETS = [0, 4, 7]; var TEAM_MEMBER_OFFSETS = [0, 4, 7];
var TEAM_LABELS = ['Marketing', 'Researchers', 'Coding']; var TEAM_LABELS = ['Marketing', 'Researchers', 'Coding'];
var MAX_DPR = 2; var MAX_DPR = 2;
var SPLASH_SCENE_FRAME_INTERVAL_MS = 1000 / 30;
var FALLBACK_AVATAR_URLS = [ var FALLBACK_AVATAR_URLS = [
'./assets/participant-avatars/01.png', './assets/participant-avatars/01.png',
'./assets/participant-avatars/02.png', './assets/participant-avatars/02.png',
@ -637,6 +638,7 @@
particles: [], particles: [],
running: true, running: true,
frameId: 0, frameId: 0,
lastRenderedAt: 0,
startedAt: performance.now(), startedAt: performance.now(),
}; };
@ -661,14 +663,21 @@
function render(now) { function render(now) {
if (!state.running) return; if (!state.running) return;
resize(); if (
drawScene( reducedMotion ||
ctx, state.lastRenderedAt === 0 ||
state.width, now - state.lastRenderedAt >= SPLASH_SCENE_FRAME_INTERVAL_MS
state.height, ) {
reducedMotion ? 1.2 : (now - state.startedAt) / 1000, resize();
state.particles 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); if (!reducedMotion) state.frameId = window.requestAnimationFrame(render);
} }

View file

@ -9,12 +9,15 @@ import { App } from './App';
import { initSentryRenderer } from './sentry'; import { initSentryRenderer } from './sentry';
import { initializeNotificationListeners } from './store'; import { initializeNotificationListeners } from './store';
import type { SplashSceneHandle } from './components/splash/splashScene';
import type { AppStartupStatus, AppStartupStep } from '@shared/types/api'; import type { AppStartupStatus, AppStartupStep } from '@shared/types/api';
declare global { declare global {
interface Window { interface Window {
__claudeTeamsUiDidInit?: boolean; __claudeTeamsUiDidInit?: boolean;
__claudeTeamsSplashStaticTimer?: number; __claudeTeamsSplashStaticTimer?: number;
__claudeTeamsSplashScene?: SplashSceneHandle;
__claudeTeamsSplashEnhancedDisabled?: boolean;
} }
} }
@ -170,6 +173,11 @@ function stopStartupTicker(): void {
startupTicker = undefined; startupTicker = undefined;
} }
function stopEnhancedSplashScene(): void {
window.__claudeTeamsSplashEnhancedDisabled = true;
window.__claudeTeamsSplashScene?.stop();
}
function mountApp(): void { function mountApp(): void {
if (root) return; if (root) return;
@ -204,6 +212,11 @@ async function bootstrapRenderer(): Promise<void> {
return; return;
} }
updateStartupSplash(nextStatus); 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) { if (nextStatus.ready) {
finished = true; finished = true;
cleanup(); cleanup();

View file

@ -7,6 +7,7 @@ import { syncRendererTelemetry } from '@renderer/sentry';
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage'; import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { scheduleStartupIdleTask } from '@renderer/utils/startupIdleTask';
import { import {
buildTaskChangePresenceKey, buildTaskChangePresenceKey,
buildTaskChangeRequestOptions, buildTaskChangeRequestOptions,
@ -21,6 +22,7 @@ import { create } from 'zustand';
import { createChangeReviewSlice } from './slices/changeReviewSlice'; import { createChangeReviewSlice } from './slices/changeReviewSlice';
import { import {
createCliInstallerSlice, createCliInstallerSlice,
getIncompleteMultimodelProviderIds,
getModelOnlyFallbackProviderIds, getModelOnlyFallbackProviderIds,
mergeCliStatusPreservingHydratedProviders, mergeCliStatusPreservingHydratedProviders,
reconcileMultimodelProviderLoading, 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_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000; const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
const TASK_LOG_ACTIVITY_PULSE_MS = 3_500; 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']> = const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']); new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout'; export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
@ -209,15 +214,16 @@ export function initializeNotificationListeners(): () => void {
const cleanupFns: (() => void)[] = []; const cleanupFns: (() => void)[] = [];
cleanupFns.push(installTeamRefreshFanoutDebugBridge()); cleanupFns.push(installTeamRefreshFanoutDebugBridge());
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null; let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
let deferredProviderStatusCleanup: (() => void) | null = null;
useStore.getState().subscribeProvisioningProgress(); useStore.getState().subscribeProvisioningProgress();
cleanupFns.push(() => { cleanupFns.push(() => {
useStore.getState().unsubscribeProvisioningProgress(); useStore.getState().unsubscribeProvisioningProgress();
}); });
// Initial data fetches. Config loads first (needed for theme), then the rest // Initial data fetches. Config loads first (needed for theme), then the
// run in parallel (no data dependencies between them). UV_THREADPOOL_SIZE=16 // immediately visible data follows. Repository grouping is owned by the
// prevents thread pool saturation even with concurrent I/O on Windows. // Sessions sidebar tab when it first mounts; scanning it here made startup
// Components also fire these from useEffect — loading guards in each action // pay for a hidden panel.
// prevent duplicate IPC calls (whichever caller starts first wins).
void (async () => { void (async () => {
// Config: fast (in-memory read) — needed for theme before first paint. // Config: fast (in-memory read) — needed for theme before first paint.
await useStore.getState().fetchConfig(); await useStore.getState().fetchConfig();
@ -241,24 +247,47 @@ export function initializeNotificationListeners(): () => void {
cliStatusTimer = setTimeout(() => { cliStatusTimer = setTimeout(() => {
const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true; const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
if (multimodelEnabled) { 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 { } else {
void useStore.getState().fetchCliStatus(); void useStore.getState().fetchCliStatus();
} }
cliStatusTimer = null; cliStatusTimer = null;
}, delayMs); }, delayMs);
} }
if (api.openCodeRuntime) { runtimeStatusTimer = setTimeout(() => {
void useStore.getState().fetchOpenCodeRuntimeStatus(); if (api.openCodeRuntime) {
} void useStore.getState().fetchOpenCodeRuntimeStatus();
if (api.codexRuntime) { }
void useStore.getState().fetchCodexRuntimeStatus(); 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 // Remaining visible startup fetches have no data dependency on each other.
// to avoid blocking teams/notifications behind a slow repository scan.
await Promise.all([ await Promise.all([
useStore.getState().fetchRepositoryGroups(),
useStore.getState().fetchAllTasks(), useStore.getState().fetchAllTasks(),
useStore.getState().fetchTeams(), useStore.getState().fetchTeams(),
useStore.getState().fetchNotifications(), useStore.getState().fetchNotifications(),
@ -267,6 +296,8 @@ export function initializeNotificationListeners(): () => void {
})(); })();
cleanupFns.push(() => { cleanupFns.push(() => {
if (cliStatusTimer) clearTimeout(cliStatusTimer); 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 // 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 // batch/priority presence pipeline. The old one-task-per-tick poll was accurate

View file

@ -4,6 +4,7 @@
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze'; import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze';
import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
@ -109,11 +110,25 @@ function isOpenCodeSummaryOnlyCatalogStatus(provider: CliProviderStatus | undefi
return provider.runtimeCapabilities?.modelCatalog?.dynamic === true; 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 { function isHydratedMultimodelProviderStatus(provider: CliProviderStatus | undefined): boolean {
if (!provider) { if (!provider) {
return false; return false;
} }
if (isDeferredMultimodelProviderStatus(provider)) {
return false;
}
if (isModelOnlyFallbackProviderStatus(provider)) { if (isModelOnlyFallbackProviderStatus(provider)) {
return false; 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( export function getIncompleteMultimodelProviderIds(
status: CliInstallationStatus | null status: CliInstallationStatus | null
): CliProviderId[] { ): CliProviderId[] {
@ -442,7 +468,7 @@ export function mergeCliStatusPreservingHydratedProviders(
return incomingProvider; return incomingProvider;
} }
if (shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) { if (shouldPreserveCurrentProviderStatus(currentProvider, incomingProvider)) {
return mergeProviderCatalogCache(incomingProvider, currentProvider); return mergePreservedHydratedProviderStatus(incomingProvider, currentProvider);
} }
// Preserve the current reference when content is identical so the // Preserve the current reference when content is identical so the
// providers array stays reference-stable across steady-state IPC polls. // providers array stays reference-stable across steady-state IPC polls.
@ -659,7 +685,10 @@ export interface CliInstallerSlice {
codexRuntimeError: string | null; codexRuntimeError: string | null;
// Actions // Actions
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>; bootstrapCliStatus: (options?: {
multimodelEnabled?: boolean;
providerStatusMode?: 'full' | 'defer';
}) => Promise<void>;
fetchCliStatus: () => Promise<void>; fetchCliStatus: () => Promise<void>;
fetchCliProviderStatus: ( fetchCliProviderStatus: (
providerId: CliProviderId, providerId: CliProviderId,
@ -714,24 +743,41 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
bootstrapCliStatus: async (options) => { bootstrapCliStatus: async (options) => {
if (!api.cliInstaller) return; if (!api.cliInstaller) return;
const multimodelEnabled = options?.multimodelEnabled ?? true; const multimodelEnabled = options?.multimodelEnabled ?? true;
const providerStatusMode = options?.providerStatusMode ?? 'full';
const hydrateProviders = providerStatusMode !== 'defer';
if (!multimodelEnabled) { if (!multimodelEnabled) {
return get().fetchCliStatus(); return get().fetchCliStatus();
} }
const epoch = ++cliStatusEpoch; 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( 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>>; ) as Partial<Record<CliProviderId, boolean>>;
set({ set({
cliStatus: createLoadingMultimodelCliStatus(), cliStatus: initialStatus,
cliStatusLoading: true, cliStatusLoading: true,
cliProviderStatusLoading: providerLoading, cliProviderStatusLoading: providerLoading,
cliStatusError: null, cliStatusError: null,
}); });
try { try {
const metadata = await api.cliInstaller.getStatus(); const metadata = await api.cliInstaller.getStatus(
providerStatusMode === 'defer' ? { providerStatusMode } : undefined
);
if (metadata.flavor !== 'agent_teams_orchestrator') { if (metadata.flavor !== 'agent_teams_orchestrator') {
set((state) => { set((state) => {
if (epoch !== cliStatusEpoch) { if (epoch !== cliStatusEpoch) {
@ -753,17 +799,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
return; return;
} }
const nextProviderLoading = Object.fromEntries( let pendingProviderIds: CliProviderId[] = [];
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
);
set((state) => { set((state) => {
if (epoch !== cliStatusEpoch || !state.cliStatus) { if (epoch !== cliStatusEpoch || !state.cliStatus) {
@ -771,6 +807,17 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
} }
const nextCliStatus = mergeCliStatusPreservingHydratedProviders(state.cliStatus, metadata); 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) const nextAuthState = isMultimodelCliStatus(nextCliStatus)
? buildMultimodelCliAuthState({ ? buildMultimodelCliAuthState({
status: nextCliStatus, status: nextCliStatus,
@ -800,7 +847,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
return; return;
} }
if (pendingProviderIds.length === 0) { if (!hydrateProviders || pendingProviderIds.length === 0) {
return; return;
} }
@ -818,14 +865,16 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
} }
try { try {
await Promise.allSettled( if (hydrateProviders) {
MULTIMODEL_PROVIDER_IDS.map((providerId) => await Promise.allSettled(
get().fetchCliProviderStatus(providerId, { MULTIMODEL_PROVIDER_IDS.map((providerId) =>
silent: false, get().fetchCliProviderStatus(providerId, {
epoch, silent: false,
}) epoch,
) })
); )
);
}
} finally { } finally {
if (epoch === cliStatusEpoch) { if (epoch === cliStatusEpoch) {
set({ cliStatusLoading: false }); set({ cliStatusLoading: false });

View file

@ -48,7 +48,13 @@ function getEmptyContextState(): Partial<AppState> {
return { return {
...getFullResetState(), ...getFullResetState(),
projects: [], projects: [],
projectsLoading: false,
projectsInitialized: false,
projectsError: null,
repositoryGroups: [], repositoryGroups: [],
repositoryGroupsLoading: false,
repositoryGroupsInitialized: false,
repositoryGroupsError: null,
sessions: [], sessions: [],
notifications: [], notifications: [],
unreadCount: 0, unreadCount: 0,
@ -148,8 +154,14 @@ function validateSnapshot(
return { return {
// Restored from snapshot (use fresh data for projects/repoGroups) // Restored from snapshot (use fresh data for projects/repoGroups)
projects: freshProjects, projects: freshProjects,
projectsLoading: false,
projectsInitialized: true,
projectsError: null,
selectedProjectId, selectedProjectId,
repositoryGroups: freshRepoGroups, repositoryGroups: freshRepoGroups,
repositoryGroupsLoading: false,
repositoryGroupsInitialized: true,
repositoryGroupsError: null,
selectedRepositoryId, selectedRepositoryId,
selectedWorktreeId, selectedWorktreeId,
viewMode: snapshot.viewMode, viewMode: snapshot.viewMode,
@ -306,7 +318,13 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
if (targetSnapshot) { if (targetSnapshot) {
set({ set({
projects: targetSnapshot.projects, projects: targetSnapshot.projects,
projectsLoading: false,
projectsInitialized: true,
projectsError: null,
repositoryGroups: targetSnapshot.repositoryGroups, repositoryGroups: targetSnapshot.repositoryGroups,
repositoryGroupsLoading: false,
repositoryGroupsInitialized: true,
repositoryGroupsError: null,
selectedProjectId: targetSnapshot.selectedProjectId, selectedProjectId: targetSnapshot.selectedProjectId,
selectedRepositoryId: targetSnapshot.selectedRepositoryId, selectedRepositoryId: targetSnapshot.selectedRepositoryId,
selectedWorktreeId: targetSnapshot.selectedWorktreeId, selectedWorktreeId: targetSnapshot.selectedWorktreeId,
@ -360,7 +378,9 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
set({ set({
...getEmptyContextState(), ...getEmptyContextState(),
projects: freshProjects, projects: freshProjects,
projectsInitialized: true,
repositoryGroups: freshRepoGroups, repositoryGroups: freshRepoGroups,
repositoryGroupsInitialized: true,
activeContextId: targetContextId, activeContextId: targetContextId,
isContextSwitching: false, isContextSwitching: false,
targetContextId: null, targetContextId: null,

View file

@ -19,6 +19,7 @@ export interface ProjectSlice {
projects: Project[]; projects: Project[];
selectedProjectId: string | null; selectedProjectId: string | null;
projectsLoading: boolean; projectsLoading: boolean;
projectsInitialized: boolean;
projectsError: string | null; projectsError: string | null;
// Actions // Actions
@ -35,6 +36,7 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
projects: [], projects: [],
selectedProjectId: null, selectedProjectId: null,
projectsLoading: false, projectsLoading: false,
projectsInitialized: false,
projectsError: null, projectsError: null,
// Fetch all projects from main process // Fetch all projects from main process
@ -48,7 +50,7 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
const sorted = [...projects].sort( const sorted = [...projects].sort(
(a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0) (a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)
); );
set({ projects: sorted, projectsLoading: false }); set({ projects: sorted, projectsLoading: false, projectsInitialized: true });
} catch (error) { } catch (error) {
set({ set({
projectsError: error instanceof Error ? error.message : 'Failed to fetch projects', projectsError: error instanceof Error ? error.message : 'Failed to fetch projects',

View file

@ -39,6 +39,7 @@ export interface RepositorySlice {
selectedRepositoryId: string | null; selectedRepositoryId: string | null;
selectedWorktreeId: string | null; selectedWorktreeId: string | null;
repositoryGroupsLoading: boolean; repositoryGroupsLoading: boolean;
repositoryGroupsInitialized: boolean;
repositoryGroupsError: string | null; repositoryGroupsError: string | null;
viewMode: 'flat' | 'grouped'; viewMode: 'flat' | 'grouped';
@ -62,6 +63,7 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
selectedRepositoryId: null, selectedRepositoryId: null,
selectedWorktreeId: null, selectedWorktreeId: null,
repositoryGroupsLoading: false, repositoryGroupsLoading: false,
repositoryGroupsInitialized: false,
repositoryGroupsError: null, repositoryGroupsError: null,
viewMode: 'grouped', // Default to grouped view viewMode: 'grouped', // Default to grouped view
@ -78,7 +80,11 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
'get-repository-groups' 'get-repository-groups'
); );
// Already sorted by most recent session in the scanner // 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; const ms = Date.now() - startedAt;
if (ms >= 2000) { if (ms >= 2000) {
logger.warn(`fetchRepositoryGroups slow ms=${ms} count=${groups.length}`); logger.warn(`fetchRepositoryGroups slow ms=${ms} count=${groups.length}`);

View file

@ -1,16 +1,27 @@
import type { CliInstallerProviderStatusMode } from '@shared/types';
interface RefreshCliStatusOptions { interface RefreshCliStatusOptions {
multimodelEnabled: boolean; multimodelEnabled: boolean;
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>; providerStatusMode?: CliInstallerProviderStatusMode;
bootstrapCliStatus: (options?: {
multimodelEnabled?: boolean;
providerStatusMode?: CliInstallerProviderStatusMode;
}) => Promise<void>;
fetchCliStatus: () => Promise<void>; fetchCliStatus: () => Promise<void>;
} }
export function refreshCliStatusForCurrentMode({ export function refreshCliStatusForCurrentMode({
multimodelEnabled, multimodelEnabled,
providerStatusMode,
bootstrapCliStatus, bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
}: RefreshCliStatusOptions): Promise<void> { }: RefreshCliStatusOptions): Promise<void> {
if (multimodelEnabled) { if (multimodelEnabled) {
return bootstrapCliStatus({ multimodelEnabled: true }); return bootstrapCliStatus(
providerStatusMode
? { multimodelEnabled: true, providerStatusMode }
: { multimodelEnabled: true }
);
} }
return fetchCliStatus(); return fetchCliStatus();

View 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;
}
};
}

View file

@ -41,33 +41,8 @@ export function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T>
} }
export function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> { export function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
const refsRef = React.useRef(refs); // This file is aliased over @radix-ui/react-compose-refs, so Radix imports this
refsRef.current = refs; // 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((node) => { return React.useCallback(composeRefs(...refs), refs);
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;
}, []);
} }

View file

@ -35,6 +35,7 @@ export type CliFlavor = 'claude' | 'agent_teams_orchestrator';
export type CliProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; export type CliProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key'; export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key';
export const CLI_PROVIDER_STATUS_DEFERRED_MESSAGE = 'Provider status will refresh when needed.';
export interface CliProviderConnectionInfo { export interface CliProviderConnectionInfo {
supportsOAuth: boolean; supportsOAuth: boolean;
@ -351,6 +352,16 @@ export interface CliInstallerProgress {
status?: CliInstallationStatus; 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 // Preload API
// ============================================================================= // =============================================================================
@ -360,7 +371,7 @@ export interface CliInstallerProgress {
*/ */
export interface CliInstallerAPI { export interface CliInstallerAPI {
/** Get current CLI installation status */ /** Get current CLI installation status */
getStatus: () => Promise<CliInstallationStatus>; getStatus: (options?: CliInstallerGetStatusOptions) => Promise<CliInstallationStatus>;
/** Get current runtime/auth status for a single provider */ /** Get current runtime/auth status for a single provider */
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>; getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
/** Start on-demand model verification for a single runtime provider */ /** Start on-demand model verification for a single runtime provider */

View file

@ -1,5 +1,6 @@
import React, { act } from 'react'; import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useCodexAccountSnapshot } from '../../../../src/features/codex-account/renderer/hooks/useCodexAccountSnapshot'; import { useCodexAccountSnapshot } from '../../../../src/features/codex-account/renderer/hooks/useCodexAccountSnapshot';
@ -15,6 +16,11 @@ const apiMocks = vi.hoisted(() => ({
onCodexAccountSnapshotChanged: vi.fn(() => () => undefined), onCodexAccountSnapshotChanged: vi.fn(() => () => undefined),
})); }));
type IdleCallbackForTest = (deadline: {
didTimeout: boolean;
timeRemaining: () => number;
}) => void;
vi.mock('@renderer/api', () => ({ vi.mock('@renderer/api', () => ({
api: apiMocks, api: apiMocks,
isElectronMode: () => true, isElectronMode: () => true,
@ -78,13 +84,16 @@ function createDeferred<T>() {
describe('useCodexAccountSnapshot', () => { describe('useCodexAccountSnapshot', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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(); vi.useRealTimers();
}); });
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; 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 () => { 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 () => { it('refreshes rate-limit snapshots more often while visible without flipping loading state during background polls', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
let visibilityState: DocumentVisibilityState = 'visible'; const visibilityState: DocumentVisibilityState = 'visible';
Object.defineProperty(document, 'visibilityState', { Object.defineProperty(document, 'visibilityState', {
configurable: true, configurable: true,
get: () => visibilityState, get: () => visibilityState,

View file

@ -134,6 +134,62 @@ describe('electron-builder afterPack', () => {
expect(fs.existsSync(path.join(prebuildsDir, 'darwin-x64'))).toBe(false); 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 () => { it('fails validation when a foreign-arch native binary remains in the bundle', async () => {
const tempDir = createTempDir(); const tempDir = createTempDir();
tempDirs.push(tempDir); tempDirs.push(tempDir);

View file

@ -1380,6 +1380,7 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => {
], ],
expect.objectContaining({ cwd: '/Users/test/project' }) 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'); expect(JSON.stringify(execCliMock.mock.calls[0])).not.toContain('undefined');
}); });

View file

@ -275,6 +275,161 @@ describe('cliInstaller IPC handlers', () => {
expect(cached.data?.providers[0]?.statusMessage).toBe('ChatGPT account ready'); 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 () => { it('does not let a stale in-flight provider refresh patch the cache after invalidation', async () => {
const staleProviderRequest = deferred<CliProviderStatus | null>(); const staleProviderRequest = deferred<CliProviderStatus | null>();
service.getStatus service.getStatus

View file

@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const { realpathMock } = vi.hoisted(() => ({ const { realpathMock, resolveInteractiveShellEnvBestEffortMock } = vi.hoisted(() => ({
realpathMock: vi.fn(async (value: string) => value), realpathMock: vi.fn(async (value: string) => value),
resolveInteractiveShellEnvBestEffortMock: vi.fn(
async (options?: { fallbackEnv?: NodeJS.ProcessEnv }) => options?.fallbackEnv ?? process.env
),
})); }));
// Mock dependencies before importing service // Mock dependencies before importing service
@ -83,6 +86,14 @@ vi.mock('@main/utils/cliAuthDiagLog', () => ({
appendCliAuthDiag: vi.fn(() => Promise.resolve(null)), 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 { import {
CliInstallerService, CliInstallerService,
isVersionOlder, isVersionOlder,
@ -148,6 +159,9 @@ describe('CliInstallerService', () => {
vi.clearAllMocks(); vi.clearAllMocks();
realpathMock.mockReset(); realpathMock.mockReset();
realpathMock.mockImplementation(async (value: string) => value); realpathMock.mockImplementation(async (value: string) => value);
resolveInteractiveShellEnvBestEffortMock.mockImplementation(
async (options?: { fallbackEnv?: NodeJS.ProcessEnv }) => options?.fallbackEnv ?? process.env
);
vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude'); vi.mocked(getConfiguredCliFlavor).mockReturnValue('claude');
vi.mocked(getCliFlavorUiOptions).mockReturnValue({ vi.mocked(getCliFlavorUiOptions).mockReturnValue({
displayName: 'Claude CLI', displayName: 'Claude CLI',
@ -169,6 +183,13 @@ describe('CliInstallerService', () => {
expect(status.installedVersion).toBeNull(); expect(status.installedVersion).toBeNull();
expect(status.binaryPath).toBeNull(); expect(status.binaryPath).toBeNull();
expect(status.updateAvailable).toBe(false); 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 () => { it('does not block getStatus on diagnostic file writes', async () => {
@ -325,6 +346,71 @@ describe('CliInstallerService', () => {
expect(service.getLatestStatusSnapshot()?.authLoggedIn).toBe(false); 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 () => { it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
allowConsoleLogs(); allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

View file

@ -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;
});
});

View file

@ -168,6 +168,61 @@ function createMockNotificationManager() {
} as unknown as Parameters<FileWatcher['setNotificationManager']>[0]; } 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 */ /** Helper to write a valid JSONL line */
function jsonlLine(uuid: string, text: string): string { function jsonlLine(uuid: string, text: string): string {
return ( return (
@ -256,6 +311,659 @@ describe('FileWatcher', () => {
watcher.stop(); 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 () => { 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-')); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-emfile-'));
setClaudeBasePathOverride(tempDir); setClaudeBasePathOverride(tempDir);
@ -303,14 +1011,10 @@ describe('FileWatcher', () => {
); );
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
const watcherAny = watcher as unknown as { const teamsSource = getChangeSource(watcher, 'teams');
retryTimer: NodeJS.Timeout | null; expect(teamsSource.currentPollingTimer).not.toBeNull();
teamsPollingTimer: NodeJS.Timeout | null; expect(getRetryTimer(watcher)).toBeNull();
teamsPollingPrimed: boolean; await vi.waitFor(() => expect(teamsSource.isPollingPrimed).toBe(true));
};
expect(watcherAny.teamsPollingTimer).not.toBeNull();
expect(watcherAny.retryTimer).toBeNull();
await vi.waitFor(() => expect(watcherAny.teamsPollingPrimed).toBe(true));
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
expect(events).toEqual([]); expect(events).toEqual([]);
@ -350,12 +1054,8 @@ describe('FileWatcher', () => {
tasksWatcher.emit('error', Object.assign(new Error(code), { code })); tasksWatcher.emit('error', Object.assign(new Error(code), { code }));
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
const watcherAny = watcher as unknown as { expect(getChangeSource(watcher, 'tasks').currentPollingTimer).not.toBeNull();
retryTimer: NodeJS.Timeout | null; expect(getRetryTimer(watcher)).toBeNull();
tasksPollingTimer: NodeJS.Timeout | null;
};
expect(watcherAny.tasksPollingTimer).not.toBeNull();
expect(watcherAny.retryTimer).toBeNull();
expect(tasksWatcher.close).toHaveBeenCalled(); expect(tasksWatcher.close).toHaveBeenCalled();
watcher.stop(); watcher.stop();
@ -391,12 +1091,8 @@ describe('FileWatcher', () => {
watcher.start(); watcher.start();
await vi.waitFor(() => { await vi.waitFor(() => {
const watcherAny = watcher as unknown as { expect(getChangeSource(watcher, 'teams').currentPollingTimer).not.toBeNull();
retryTimer: NodeJS.Timeout | null; expect(getRetryTimer(watcher)).toBeNull();
teamsPollingTimer: NodeJS.Timeout | null;
};
expect(watcherAny.teamsPollingTimer).not.toBeNull();
expect(watcherAny.retryTimer).toBeNull();
}); });
expect(chokidarMock.watch).toHaveBeenCalledTimes(2); expect(chokidarMock.watch).toHaveBeenCalledTimes(2);
@ -406,6 +1102,49 @@ describe('FileWatcher', () => {
fs.rmSync(tempDir, { recursive: true, force: true }); 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 () => { it('retries chokidar registry after a non-limit error without enabling polling', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-nonlimit-')); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-team-nonlimit-'));
setClaudeBasePathOverride(tempDir); setClaudeBasePathOverride(tempDir);
@ -432,12 +1171,8 @@ describe('FileWatcher', () => {
teamsWatcher.emit('error', Object.assign(new Error('permission denied'), { code: 'EACCES' })); teamsWatcher.emit('error', Object.assign(new Error('permission denied'), { code: 'EACCES' }));
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
const watcherAny = watcher as unknown as { expect(getChangeSource(watcher, 'teams').currentPollingTimer).toBeNull();
retryTimer: NodeJS.Timeout | null; expect(getRetryTimer(watcher)).not.toBeNull();
teamsPollingTimer: NodeJS.Timeout | null;
};
expect(watcherAny.teamsPollingTimer).toBeNull();
expect(watcherAny.retryTimer).not.toBeNull();
expect(teamsWatcher.close).toHaveBeenCalled(); expect(teamsWatcher.close).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(2000);
@ -472,12 +1207,8 @@ describe('FileWatcher', () => {
await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(2));
const watcherAny = watcher as unknown as { expect(getChangeSource(watcher, 'teams').currentPollingTimer).toBeNull();
teamsPollingTimer: NodeJS.Timeout | null; expect(getChangeSource(watcher, 'tasks').currentPollingTimer).toBeNull();
tasksPollingTimer: NodeJS.Timeout | null;
};
expect(watcherAny.teamsPollingTimer).toBeNull();
expect(watcherAny.tasksPollingTimer).toBeNull();
expect(watchMock).not.toHaveBeenCalledWith(teamsDir, expect.anything(), expect.anything()); expect(watchMock).not.toHaveBeenCalledWith(teamsDir, expect.anything(), expect.anything());
expect(watchMock).not.toHaveBeenCalledWith(tasksDir, expect.anything(), expect.anything()); expect(watchMock).not.toHaveBeenCalledWith(tasksDir, expect.anything(), expect.anything());
expectChokidarOptions(getChokidarWatcherForRoot(teamsDir)); expectChokidarOptions(getChokidarWatcherForRoot(teamsDir));
@ -513,6 +1244,7 @@ describe('FileWatcher', () => {
const teamsWatcher = getChokidarWatcherForRoot(teamsDir); const teamsWatcher = getChokidarWatcherForRoot(teamsDir);
teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'config.json')); 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', 'inboxes', 'user.json'));
teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'sentMessages.json')); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'sentMessages.json'));
teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'processes.json')); teamsWatcher.emit('change', path.join(teamsDir, 'base-1', 'processes.json'));
@ -524,6 +1256,7 @@ describe('FileWatcher', () => {
expect(events).toEqual([ expect(events).toEqual([
{ type: 'config', teamName: 'base-1', detail: 'config.json' }, { 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: 'inboxes/user.json' },
{ type: 'inbox', teamName: 'base-1', detail: 'sentMessages.json' }, { type: 'inbox', teamName: 'base-1', detail: 'sentMessages.json' },
{ type: 'process', teamName: 'base-1', detail: 'processes.json' }, { type: 'process', teamName: 'base-1', detail: 'processes.json' },
@ -782,6 +1515,49 @@ describe('FileWatcher', () => {
fs.rmSync(tempDir, { recursive: true, force: true }); 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 () => { it('ignores events from an old chokidar generation after registry rebuild', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-generation-')); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-chokidar-generation-'));
setClaudeBasePathOverride(tempDir); setClaudeBasePathOverride(tempDir);
@ -1048,16 +1824,13 @@ describe('FileWatcher', () => {
const events: unknown[] = []; const events: unknown[] = [];
watcher.on('team-change', (event) => events.push(event)); watcher.on('team-change', (event) => events.push(event));
const watcherAny = watcher as unknown as { setWatcherActive(watcher);
isWatching: boolean; const teamsSource = getChangeSource(watcher, 'teams');
pollTeamsForChanges: () => Promise<void>; await teamsSource.pollOnce();
};
watcherAny.isWatching = true;
await watcherAny.pollTeamsForChanges();
expect(events).toEqual([]); expect(events).toEqual([]);
fs.writeFileSync(inboxPath, '[{"messageId":"m1"}]', 'utf8'); fs.writeFileSync(inboxPath, '[{"messageId":"m1"}]', 'utf8');
await watcherAny.pollTeamsForChanges(); await teamsSource.pollOnce();
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
expect(events).toEqual([{ type: 'inbox', teamName: 'base-1', detail: 'inboxes/user.json' }]); expect(events).toEqual([{ type: 'inbox', teamName: 'base-1', detail: 'inboxes/user.json' }]);
@ -1083,16 +1856,13 @@ describe('FileWatcher', () => {
const events: unknown[] = []; const events: unknown[] = [];
watcher.on('team-change', (event) => events.push(event)); watcher.on('team-change', (event) => events.push(event));
const watcherAny = watcher as unknown as { setWatcherActive(watcher);
isWatching: boolean; const tasksSource = getChangeSource(watcher, 'tasks');
pollTasksForChanges: () => Promise<void>; await tasksSource.pollOnce();
};
watcherAny.isWatching = true;
await watcherAny.pollTasksForChanges();
expect(events).toEqual([]); expect(events).toEqual([]);
fs.writeFileSync(taskPath, '{"status":"running"}', 'utf8'); fs.writeFileSync(taskPath, '{"status":"running"}', 'utf8');
await watcherAny.pollTasksForChanges(); await tasksSource.pollOnce();
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
expect(events).toEqual([ expect(events).toEqual([

View file

@ -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 () => { it('returns a verified OpenCode binary from nvm when desktop PATH misses npm globals', async () => {
const olderBinaryPath = path.join( const olderBinaryPath = path.join(
tempRoot!, tempRoot!,

View file

@ -142,7 +142,7 @@ describePosix('OpenCode packaged-runtime preflight integration', () => {
[ [
'#!/bin/sh', '#!/bin/sh',
'if [ "$1" = "-e" ]; then', 'if [ "$1" = "-e" ]; then',
' printf "%s" "$FAKE_NODE_PATH"', ' printf "{\\"execPath\\":\\"%s\\",\\"version\\":\\"%s\\"}" "$FAKE_NODE_PATH" "22.0.0"',
' exit 0', ' exit 0',
'fi', 'fi',
'echo "unexpected node args: $*" >&2', 'echo "unexpected node args: $*" >&2',

View file

@ -7,9 +7,8 @@ import type { PathLike } from 'fs';
const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>(); const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
const mockGetShellPreferredHome = vi.fn<() => string>(); const mockGetShellPreferredHome = vi.fn<() => string>();
const mockGetClaudeBasePath = vi.fn<() => string>(); const mockGetClaudeBasePath = vi.fn<() => string>();
const mockResolveInteractiveShellEnvBestEffort = vi.fn< const mockResolveInteractiveShellEnvBestEffort =
(options?: unknown) => Promise<NodeJS.ProcessEnv> vi.fn<(options?: unknown) => Promise<NodeJS.ProcessEnv>>();
>();
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'agent_teams_orchestrator'>(); const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'agent_teams_orchestrator'>();
const mockGetDoctorInvokedCandidates = vi.fn<(commandName: string) => Promise<string[]>>(); const mockGetDoctorInvokedCandidates = vi.fn<(commandName: string) => Promise<string[]>>();
@ -120,6 +119,7 @@ describe('ClaudeBinaryResolver', () => {
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary); await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled();
}); });
it('prefers the dedicated CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH override', async () => { 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); await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled();
}); });
it('does not wait for shell env before using an explicit absolute runtime override', async () => { 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); await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1); expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled();
}); });
it('finds npm-local Claude install in the vendor bin directory', async () => { it('finds npm-local Claude install in the vendor bin directory', async () => {

View file

@ -146,6 +146,80 @@ describe('TeamLaunchFailureArtifactPack', () => {
expect(launchStateContent).not.toContain('sk-ant-'); 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', () => { it('redacts common bearer and token-shaped secrets', () => {
const redacted = redactLaunchFailureArtifactText( const redacted = redactLaunchFailureArtifactText(
'Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456 token: abcdefghijklmnopqrstuvwxyz123456 ANTHROPIC_AUTH_TOKEN=lmstudio CODEX_API_KEY="quoted-codex-token" OPENROUTER_API_KEY=\'quoted-router-token\'' 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456 token: abcdefghijklmnopqrstuvwxyz123456 ANTHROPIC_AUTH_TOKEN=lmstudio CODEX_API_KEY="quoted-codex-token" OPENROUTER_API_KEY=\'quoted-router-token\''

View file

@ -21,10 +21,13 @@ const hoisted = vi.hoisted(() => ({
isPackaged: false, isPackaged: false,
version: '9.9.9-test', 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, cachedShellEnv: null as NodeJS.ProcessEnv | null,
resolveInteractiveShellEnvMock: vi.fn<ResolveInteractiveShellEnvMock>( resolveInteractiveShellEnvMock: vi.fn<ResolveInteractiveShellEnvMock>(
async () => ({} as NodeJS.ProcessEnv) async () => ({}) as NodeJS.ProcessEnv
), ),
})); }));
@ -65,6 +68,10 @@ import {
} from '@main/services/team/TeamMcpConfigBuilder'; } from '@main/services/team/TeamMcpConfigBuilder';
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
function nodeRuntimeProbeStdout(execPath: string, version = '20.11.0'): string {
return JSON.stringify({ execPath, version });
}
describe('TeamMcpConfigBuilder', () => { describe('TeamMcpConfigBuilder', () => {
const createdPaths: string[] = []; const createdPaths: string[] = [];
const createdDirs: string[] = []; const createdDirs: string[] = [];
@ -95,7 +102,9 @@ describe('TeamMcpConfigBuilder', () => {
function readGeneratedServer( function readGeneratedServer(
configPath: string 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 raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
mcpServers?: Record< mcpServers?: Record<
@ -206,7 +215,10 @@ describe('TeamMcpConfigBuilder', () => {
setPackagedMode(false); setPackagedMode(false);
setResourcesPath(undefined); setResourcesPath(undefined);
hoisted.execCliMock.mockClear(); hoisted.execCliMock.mockClear();
hoisted.execCliMock.mockResolvedValue({ stdout: '/mock/node', stderr: '' }); hoisted.execCliMock.mockResolvedValue({
stdout: nodeRuntimeProbeStdout('/mock/node'),
stderr: '',
});
hoisted.cachedShellEnv = null; hoisted.cachedShellEnv = null;
hoisted.resolveInteractiveShellEnvMock.mockClear(); hoisted.resolveInteractiveShellEnvMock.mockClear();
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({}); hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({});
@ -318,7 +330,7 @@ describe('TeamMcpConfigBuilder', () => {
expect(readGeneratedServer(configPath)?.command).toBe('/mock/node'); expect(readGeneratedServer(configPath)?.command).toBe('/mock/node');
expect(hoisted.execCliMock).toHaveBeenCalledWith( expect(hoisted.execCliMock).toHaveBeenCalledWith(
'node', 'node',
['-e', 'process.stdout.write(process.execPath)'], ['-e', expect.stringContaining('process.versions.node')],
expect.objectContaining({ expect.objectContaining({
encoding: 'utf-8', encoding: 'utf-8',
timeout: 5000, timeout: 5000,
@ -346,7 +358,7 @@ describe('TeamMcpConfigBuilder', () => {
expect(command).toBe('node'); expect(command).toBe('node');
const env = options?.env as NodeJS.ProcessEnv | undefined; const env = options?.env as NodeJS.ProcessEnv | undefined;
expect(env?.PATH?.split(path.delimiter)[0]).toBe('/mock-shell-node-bin'); 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(); const builder = new TeamMcpConfigBuilder();
@ -361,64 +373,64 @@ describe('TeamMcpConfigBuilder', () => {
it.each(['linux', 'darwin', 'win32'] as const)( it.each(['linux', 'darwin', 'win32'] as const)(
'uses the packaged Electron Node runtime for %s packaged MCP launches', 'uses the packaged Electron Node runtime for %s packaged MCP launches',
async (platform) => { async (platform) => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath'); const execPathDescriptor = Object.getOwnPropertyDescriptor(process, 'execPath');
const electronBinary = const electronBinary =
platform === 'win32' platform === 'win32'
? 'C:\\Program Files\\Agent Teams AI\\agent-teams-ai.exe' ? 'C:\\Program Files\\Agent Teams AI\\agent-teams-ai.exe'
: '/opt/Agent Teams AI/agent-teams-ai'; : '/opt/Agent Teams AI/agent-teams-ai';
setPackagedMode(true, '3.0.0'); setPackagedMode(true, '3.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir); createdDirs.push(resourcesDir);
createPackagedServerBundle(resourcesDir, '// packaged server'); createPackagedServerBundle(resourcesDir, '// packaged server');
setResourcesPath(resourcesDir); setResourcesPath(resourcesDir);
hoisted.execCliMock.mockResolvedValue({ hoisted.execCliMock.mockResolvedValue({
stdout: 'agent-teams-electron-node-ok', stdout: 'agent-teams-electron-node-ok',
stderr: '', 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' },
}); });
expect(server?.command).toBe(electronBinary);
expect(server?.args).toEqual([expectedEntry]); Object.defineProperty(process, 'platform', {
expect(server?.env?.ELECTRON_RUN_AS_NODE).toBe('1'); value: platform,
expect(hoisted.execCliMock).toHaveBeenCalledTimes(1); configurable: true,
expect(hoisted.execCliMock).toHaveBeenCalledWith( });
electronBinary, Object.defineProperty(process, 'execPath', {
['-e', 'process.stdout.write("agent-teams-electron-node-ok")'], value: electronBinary,
expect.objectContaining({ configurable: true,
env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }), writable: true,
}) });
);
} finally { try {
if (platformDescriptor) { const launchSpec = await resolveAgentTeamsMcpLaunchSpec();
Object.defineProperty(process, 'platform', platformDescriptor); 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; const env = options?.env as NodeJS.ProcessEnv | undefined;
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
expect(command).toBe('node'); 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`); 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 () => { it('falls back to strict shell env lookup when fast Node lookup reports an empty path', async () => {
mockBuiltWorkspaceEntryAvailable(); mockBuiltWorkspaceEntryAvailable();
hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({ hoisted.resolveInteractiveShellEnvMock.mockResolvedValue({
@ -475,7 +574,7 @@ describe('TeamMcpConfigBuilder', () => {
const env = options?.env as NodeJS.ProcessEnv | undefined; const env = options?.env as NodeJS.ProcessEnv | undefined;
if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') { if (env?.PATH?.split(path.delimiter)[0] === '/strict-shell-node-bin') {
expect(command).toBe('node'); expect(command).toBe('node');
return { stdout: '/strict-shell-node-bin/node', stderr: '' }; return { stdout: nodeRuntimeProbeStdout('/strict-shell-node-bin/node'), stderr: '' };
} }
if (!returnedEmptyPath) { if (!returnedEmptyPath) {
returnedEmptyPath = true; returnedEmptyPath = true;
@ -500,7 +599,7 @@ describe('TeamMcpConfigBuilder', () => {
process.env.NODE_BINARY = '/explicit/node'; process.env.NODE_BINARY = '/explicit/node';
hoisted.execCliMock.mockImplementationOnce(async (command) => { hoisted.execCliMock.mockImplementationOnce(async (command) => {
expect(command).toBe('/explicit/node'); expect(command).toBe('/explicit/node');
return { stdout: '/explicit/node', stderr: '' }; return { stdout: nodeRuntimeProbeStdout('/explicit/node'), stderr: '' };
}); });
try { try {
@ -643,7 +742,10 @@ describe('TeamMcpConfigBuilder', () => {
createdPaths.push(configPath); createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { 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']); expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
@ -744,7 +846,10 @@ describe('TeamMcpConfigBuilder', () => {
createdPaths.push(configPath); createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { 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']); expect(Object.keys(parsed.mcpServers).sort()).toEqual(['agent-teams', 'github', 'linear']);

View file

@ -27,7 +27,10 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
vi.mock('@main/utils/childProcess', () => ({ vi.mock('@main/utils/childProcess', () => ({
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => { execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === '-e' && args[1]?.includes('process.execPath')) { 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')) { if (args.includes('model') && args.includes('list')) {
return { return {

View file

@ -52,6 +52,13 @@ vi.mock('@main/services/team/TeamTaskReader', () => ({
vi.mock('@main/utils/childProcess', () => ({ vi.mock('@main/utils/childProcess', () => ({
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => { 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') { if (args[0] === 'model') {
return { return {
stdout: JSON.stringify({ stdout: JSON.stringify({

View file

@ -80,6 +80,13 @@ async function setupRunningTeam(teamName: string) {
const { child, writeSpy } = createFakeChild(); const { child, writeSpy } = createFakeChild();
vi.mocked(spawnCli).mockReturnValue(child as any); vi.mocked(spawnCli).mockReturnValue(child as any);
vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => { 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 providerIndex = args.indexOf('--provider');
const providerId = providerIndex >= 0 ? args[providerIndex + 1] : 'anthropic'; const providerId = providerIndex >= 0 ? args[providerIndex + 1] : 'anthropic';
if (args[0] === 'model' && args[1] === 'list') { if (args[0] === 'model' && args[1] === 'list') {

View file

@ -31,6 +31,14 @@ vi.mock('@main/services/infrastructure/NotificationManager', () => ({
})); }));
const defaultExecCliMockImplementation = async (_binaryPath: string | null, args: string[]) => { 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') { if (args[0] === 'model') {
return { return {
stdout: JSON.stringify({ stdout: JSON.stringify({

View file

@ -26,7 +26,10 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
vi.mock('@main/utils/childProcess', () => ({ vi.mock('@main/utils/childProcess', () => ({
execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => { execCli: vi.fn(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === '-e' && args[1]?.includes('process.execPath')) { 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')) { if (args.includes('model') && args.includes('list')) {
return { return {

View file

@ -1,6 +1,7 @@
import React, { act } from 'react'; import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
@ -173,6 +174,14 @@ vi.mock('@renderer/store', () => {
import { CliStatusBanner } from '@renderer/components/dashboard/CliStatusBanner'; import { CliStatusBanner } from '@renderer/components/dashboard/CliStatusBanner';
import { CliStatusSection } from '@renderer/components/settings/sections/CliStatusSection'; 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( function createInstalledCliStatus(
overrides?: Partial<Record<string, unknown>> overrides?: Partial<Record<string, unknown>>
): 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', () => { describe('CLI status visibility during completed install state', () => {
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; 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 () => { it('shows an OpenCode install action on the dashboard when the OpenCode CLI is missing', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle'; storeState.cliInstallerState = 'idle';
@ -1089,6 +1308,15 @@ describe('CLI status visibility during completed install state', () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle'; storeState.cliInstallerState = 'idle';
storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed'))); 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'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
@ -1099,6 +1327,19 @@ describe('CLI status visibility during completed install state', () => {
await Promise.resolve(); 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; const onSelectBackend = providerRuntimeSettingsDialogProps?.onSelectBackend;
expect(onSelectBackend).toBeTypeOf('function'); expect(onSelectBackend).toBeTypeOf('function');

View file

@ -1,8 +1,7 @@
import React, { act } from 'react'; import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; 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 { interface StoreState {
cliStatus: Record<string, unknown> | null; cliStatus: Record<string, unknown> | null;
@ -28,9 +27,9 @@ interface StoreState {
const storeState = {} as StoreState; const storeState = {} as StoreState;
const codexAccountHookState = { const codexAccountHookState = {
snapshot: null as CodexAccountSnapshotDto | null, snapshot: null,
loading: false, loading: false,
error: null as string | null, error: null,
refresh: vi.fn(() => Promise.resolve(undefined)), refresh: vi.fn(() => Promise.resolve(undefined)),
startChatgptLogin: vi.fn(() => Promise.resolve(true)), startChatgptLogin: vi.fn(() => Promise.resolve(true)),
cancelChatgptLogin: 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'; import { GlobalProviderStatusHeader } from '@renderer/components/common/GlobalProviderStatusHeader';
function createProvider( function createProvider(overrides: Record<string, unknown>): Record<string, unknown> {
overrides: Partial<Record<string, unknown>> & {
providerId: string;
displayName: string;
}
): Record<string, unknown> {
return { return {
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true, supported: true,
authenticated: false, authenticated: false,
authMethod: null, authMethod: null,
verificationState: 'verified', verificationState: 'unknown',
statusMessage: null, statusMessage: 'Checking...',
detailMessage: null, detailMessage: null,
models: [], models: [],
modelVerificationState: 'idle', modelVerificationState: 'idle',
@ -80,10 +76,7 @@ function createProvider(
capabilities: { capabilities: {
teamLaunch: true, teamLaunch: true,
oneShot: true, oneShot: true,
extensions: { extensions: {},
plugins: { status: 'unsupported' },
mcp: { status: 'unsupported' },
},
}, },
backend: null, backend: null,
availableBackends: [], availableBackends: [],
@ -127,18 +120,15 @@ function setFocusedTab(type: string): void {
describe('GlobalProviderStatusHeader', () => { describe('GlobalProviderStatusHeader', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = null; storeState.cliStatus = createMultimodelStatus([createProvider({})]);
storeState.cliStatusLoading = false; storeState.cliStatusLoading = false;
storeState.cliProviderStatusLoading = {}; storeState.cliProviderStatusLoading = { anthropic: true };
storeState.appConfig = { storeState.appConfig = {
general: { general: {
multimodelEnabled: true, multimodelEnabled: true,
}, },
}; };
setFocusedTab('team'); setFocusedTab('team');
codexAccountHookState.snapshot = null;
codexAccountHookState.loading = false;
codexAccountHookState.error = null;
}); });
afterEach(() => { afterEach(() => {
@ -146,17 +136,7 @@ describe('GlobalProviderStatusHeader', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('shows loading providers on non-dashboard screens', async () => { it('shows provider activity on non-dashboard screens', async () => {
storeState.cliStatus = createMultimodelStatus([
createProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
verificationState: 'unknown',
statusMessage: 'Checking...',
}),
]);
storeState.cliProviderStatusLoading = { anthropic: true };
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
const root = createRoot(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'); setFocusedTab('dashboard');
storeState.cliStatus = createMultimodelStatus([
createProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
verificationState: 'unknown',
statusMessage: 'Checking...',
}),
]);
storeState.cliProviderStatusLoading = { anthropic: true };
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
const root = createRoot(host); const root = createRoot(host);
@ -204,165 +174,4 @@ describe('GlobalProviderStatusHeader', () => {
await Promise.resolve(); 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();
});
});
}); });

Some files were not shown because too many files have changed in this diff Show more