merge(dev): sync dev into main
This commit is contained in:
commit
5ae2d1be03
93 changed files with 4506 additions and 637 deletions
|
|
@ -1,6 +0,0 @@
|
|||
- generic [ref=e1]:
|
||||
- img
|
||||
- img [ref=e2]
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e13]: Agent Teams AI
|
||||
- generic [ref=e15]: Get more done by doing less.
|
||||
|
|
@ -107,9 +107,9 @@ If you want the FRESHEST version, clone the repo and run it from the `dev` branc
|
|||
|
||||
## What is this
|
||||
|
||||
An orchestration layer for AI agent teams across Claude and Codex.
|
||||
An orchestration layer for AI agent teams across Claude, Codex, and OpenCode.
|
||||
|
||||
- **Claude + Codex orchestration** — auto-detect available Claude/Codex runtimes and use the provider access you already have - subscriptions or API keys
|
||||
- **Claude + Codex + OpenCode orchestration** — auto-detect available Claude/Codex/OpenCode runtimes and use the provider access you already have - subscriptions or API keys
|
||||
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
|
||||
- **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments
|
||||
- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { computed } from "vue";
|
||||
import { supportedLocales, defaultLocale } from "~/data/i18n";
|
||||
import { supportedLocales, defaultLocale, getLocaleMeta } from "~/data/i18n";
|
||||
import { getContent } from "~/data/content";
|
||||
import type { LocaleCode } from "~/data/i18n";
|
||||
|
||||
|
|
@ -21,15 +21,20 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = config.public.siteUrl || "https://example.com";
|
||||
const siteName = (config as any)?.site?.name || "Agent Teams";
|
||||
const siteUrl = ((config.public.siteUrl as string) || "https://example.com").replace(/\/+$/, "");
|
||||
const siteName = "Agent Teams";
|
||||
const switchLocale = useSwitchLocalePath();
|
||||
|
||||
const title = computed(() => t(titleKey));
|
||||
const description = computed(() => t(descriptionKey));
|
||||
|
||||
const canonicalPath = computed(() => route.path);
|
||||
const canonicalUrl = computed(() => `${siteUrl}${canonicalPath.value}`);
|
||||
const toSiteUrl = (pathOrUrl: string) => {
|
||||
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
||||
const path = pathOrUrl === "/" ? "/" : `/${pathOrUrl.replace(/^\/+/, "")}`;
|
||||
return `${siteUrl}${path}`;
|
||||
};
|
||||
const canonicalUrl = computed(() => toSiteUrl(canonicalPath.value));
|
||||
|
||||
const resolvedImage = computed<PageSeoImage>(() => {
|
||||
if (options.image) return options.image;
|
||||
|
|
@ -38,14 +43,14 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
width: 1200,
|
||||
height: 630,
|
||||
type: "image/png",
|
||||
alt: `${siteName} — AI agent orchestration`
|
||||
alt: `${siteName} - AI agent orchestration`
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedImageUrl = computed(() => {
|
||||
// Если сборщик вернул относительный путь — сделаем абсолютный.
|
||||
// Если сборщик вернул относительный путь - сделаем абсолютный.
|
||||
const url = resolvedImage.value.url;
|
||||
return url.startsWith("http") ? url : new URL(url, siteUrl).toString();
|
||||
return toSiteUrl(url);
|
||||
});
|
||||
|
||||
useSeoMeta({
|
||||
|
|
@ -57,7 +62,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
ogSiteName: siteName,
|
||||
ogUrl: canonicalUrl,
|
||||
ogImage: resolvedImageUrl,
|
||||
ogImageType: computed(() => resolvedImage.value.type) as any,
|
||||
ogImageType: computed(() => resolvedImage.value.type),
|
||||
ogImageWidth: computed(() => (resolvedImage.value.width ? String(resolvedImage.value.width) : undefined)),
|
||||
ogImageHeight: computed(() => (resolvedImage.value.height ? String(resolvedImage.value.height) : undefined)),
|
||||
ogImageAlt: computed(() => resolvedImage.value.alt),
|
||||
|
|
@ -72,32 +77,70 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
});
|
||||
|
||||
useHead(() => {
|
||||
const currentLocale = getLocaleMeta(locale.value as LocaleCode);
|
||||
const links: { rel: string; hreflang?: string; href: string }[] = supportedLocales.map((locale) => {
|
||||
const path = switchLocale(locale.code) || canonicalPath.value;
|
||||
return {
|
||||
rel: "alternate",
|
||||
hreflang: locale.code,
|
||||
href: `${siteUrl}${path}`
|
||||
hreflang: locale.iso,
|
||||
href: toSiteUrl(path)
|
||||
};
|
||||
});
|
||||
|
||||
const defaultPath = switchLocale(defaultLocale) || canonicalPath.value;
|
||||
links.push({ rel: "alternate", hreflang: "x-default", href: `${siteUrl}${defaultPath}` });
|
||||
links.push({ rel: "alternate", hreflang: "x-default", href: toSiteUrl(defaultPath) });
|
||||
links.push({ rel: "canonical", href: canonicalUrl.value });
|
||||
|
||||
const jsonLd: any[] = [
|
||||
const ogLocale = currentLocale.iso.replace("-", "_");
|
||||
const ogAlternateLocales = supportedLocales
|
||||
.filter((locale) => locale.iso !== currentLocale.iso)
|
||||
.map((locale) => locale.iso.replace("-", "_"));
|
||||
|
||||
const normalizedPath = canonicalPath.value === "/" ? "/" : canonicalPath.value.replace(/\/+$/, "");
|
||||
const localizedHomePath = currentLocale.code === defaultLocale ? "/" : `/${currentLocale.code}`;
|
||||
const isHome = normalizedPath === localizedHomePath;
|
||||
const isDownload = normalizedPath.endsWith("/download");
|
||||
const organizationId = `${siteUrl}/#organization`;
|
||||
const websiteId = `${siteUrl}/#website`;
|
||||
const softwareId = `${siteUrl}/#software`;
|
||||
const webpageId = `${canonicalUrl.value}#webpage`;
|
||||
|
||||
const jsonLd: Record<string, unknown>[] = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"@id": websiteId,
|
||||
name: siteName,
|
||||
url: siteUrl
|
||||
url: siteUrl,
|
||||
inLanguage: currentLocale.iso,
|
||||
publisher: { "@id": organizationId }
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"@id": webpageId,
|
||||
name: title.value,
|
||||
description: description.value,
|
||||
url: canonicalUrl.value,
|
||||
inLanguage: currentLocale.iso,
|
||||
isPartOf: { "@id": websiteId },
|
||||
about: { "@id": softwareId },
|
||||
publisher: { "@id": organizationId },
|
||||
primaryImageOfPage: {
|
||||
"@type": "ImageObject",
|
||||
"@id": `${resolvedImageUrl.value}#primaryimage`,
|
||||
url: resolvedImageUrl.value,
|
||||
width: resolvedImage.value.width,
|
||||
height: resolvedImage.value.height
|
||||
}
|
||||
},
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"@id": organizationId,
|
||||
name: siteName,
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/favicon.ico`,
|
||||
logo: toSiteUrl("/logo-192.png"),
|
||||
sameAs: [
|
||||
`https://github.com/${config.public.githubRepo}`
|
||||
]
|
||||
|
|
@ -105,17 +148,22 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
];
|
||||
|
||||
// Для главной и страницы скачивания добавим более "вкусную" разметку.
|
||||
const isDownload = canonicalPath.value.endsWith("/download");
|
||||
const isHome = canonicalPath.value === "/";
|
||||
if (isHome || isDownload) {
|
||||
jsonLd.push({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": softwareId,
|
||||
name: siteName,
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Windows, macOS, Linux",
|
||||
description: description.value,
|
||||
url: canonicalUrl.value,
|
||||
mainEntityOfPage: { "@id": webpageId },
|
||||
author: { "@id": organizationId },
|
||||
publisher: { "@id": organizationId },
|
||||
image: resolvedImageUrl.value,
|
||||
screenshot: toSiteUrl("/screenshots/1.jpg"),
|
||||
softwareVersion: "latest",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
|
|
@ -125,13 +173,16 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
});
|
||||
}
|
||||
|
||||
// FAQ rich snippets — Google показывает их прямо в выдаче
|
||||
// FAQ rich snippets - Google показывает их прямо в выдаче
|
||||
if (isHome) {
|
||||
const content = getContent(locale.value as LocaleCode);
|
||||
if (content.faq?.length) {
|
||||
jsonLd.push({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"@id": `${canonicalUrl.value}#faq`,
|
||||
inLanguage: currentLocale.iso,
|
||||
isPartOf: { "@id": webpageId },
|
||||
mainEntity: content.faq.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
|
|
@ -146,7 +197,7 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
}
|
||||
|
||||
return {
|
||||
htmlAttrs: { lang: locale.value || "en" },
|
||||
htmlAttrs: { lang: currentLocale.iso, dir: "dir" in currentLocale ? currentLocale.dir : "ltr" },
|
||||
link: links,
|
||||
meta: [
|
||||
{ name: "author", content: "Agent Teams" },
|
||||
|
|
@ -154,6 +205,9 @@ export const usePageSeo = (titleKey: string, descriptionKey: string, options: Pa
|
|||
{ name: "apple-mobile-web-app-title", content: siteName },
|
||||
{ name: "format-detection", content: "telephone=no" },
|
||||
{ name: "theme-color", content: "#00f0ff" },
|
||||
{ name: "googlebot", content: options.robots || "index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" },
|
||||
{ property: "og:locale", content: ogLocale },
|
||||
...ogAlternateLocales.map((content) => ({ property: "og:locale:alternate", content })),
|
||||
{ name: "keywords", content: "claude code, agent teams, AI agents, kanban board, code review, multi-agent orchestration, desktop app, free, open source" }
|
||||
],
|
||||
script: jsonLd.map((item) => ({
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ export const sitemapPages = [
|
|||
"/download"
|
||||
] as const;
|
||||
|
||||
export type SitemapPagePath = (typeof sitemapPages)[number];
|
||||
|
||||
export const getLocaleMeta = (localeCode: LocaleCode) =>
|
||||
supportedLocales.find((locale) => locale.code === localeCode) ?? supportedLocales[0];
|
||||
|
||||
export const getLocalizedPagePath = (page: SitemapPagePath, localeCode: LocaleCode): string => {
|
||||
if (localeCode === defaultLocale) return page;
|
||||
return page === "/" ? `/${localeCode}` : `/${localeCode}${page}`;
|
||||
};
|
||||
|
||||
/** Generates i18n routes for a given list of pages */
|
||||
const buildI18nRoutes = (source: readonly string[]): string[] => {
|
||||
const routes: string[] = [];
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
},
|
||||
"meta": {
|
||||
"homeTitle": "Agent Teams - تنسيق وكلاء الذكاء الاصطناعي للمطورين",
|
||||
"homeDescription": "تطبيق سطح مكتب مجاني ومفتوح المصدر لتشكيل فرق وكلاء الذكاء الاصطناعي. لوحة كانبان، مراجعة الكود، تواصل بين الفرق. يعمل محلياً بالكامل."
|
||||
"homeDescription": "تطبيق سطح مكتب مجاني ومفتوح المصدر لتشكيل فرق وكلاء الذكاء الاصطناعي. لوحة كانبان، مراجعة الكود، تواصل بين الفرق. يعمل محلياً بالكامل.",
|
||||
"downloadTitle": "تنزيل Agent Teams لنظام macOS وWindows وLinux",
|
||||
"downloadDescription": "نزّل Agent Teams لنظام macOS وWindows وLinux. تطبيق سطح مكتب مجاني ومفتوح المصدر لفرق وكلاء Claude وCodex وOpenCode."
|
||||
},
|
||||
"error": {
|
||||
"notFoundTitle": "الصفحة غير موجودة",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
},
|
||||
"meta": {
|
||||
"homeTitle": "Agent Teams - KI-Agenten-Orchestrierung für Entwickler",
|
||||
"homeDescription": "Kostenlose Open-Source-Desktop-App für KI-Agenten-Teams. Kanban-Board, Code-Review, teamübergreifende Kommunikation. Läuft vollständig lokal."
|
||||
"homeDescription": "Kostenlose Open-Source-Desktop-App für KI-Agenten-Teams. Kanban-Board, Code-Review, teamübergreifende Kommunikation. Läuft vollständig lokal.",
|
||||
"downloadTitle": "Agent Teams für macOS, Windows und Linux herunterladen",
|
||||
"downloadDescription": "Laden Sie Agent Teams für macOS, Windows und Linux herunter. Kostenlose Open-Source-Desktop-App für Claude-, Codex- und OpenCode-Agententeams."
|
||||
},
|
||||
"error": {
|
||||
"notFoundTitle": "Seite nicht gefunden",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
},
|
||||
"meta": {
|
||||
"homeTitle": "Agent Teams - AI Agent Orchestration for Developers",
|
||||
"homeDescription": "Free, open-source desktop app for assembling AI agent teams. Kanban board, code review, cross-team communication. Runs entirely locally."
|
||||
"homeDescription": "Free, open-source desktop app for assembling AI agent teams. Kanban board, code review, cross-team communication. Runs entirely locally.",
|
||||
"downloadTitle": "Download Agent Teams for macOS, Windows, and Linux",
|
||||
"downloadDescription": "Download Agent Teams for macOS, Windows, and Linux. Free open-source desktop app for Claude, Codex, and OpenCode agent teams."
|
||||
},
|
||||
"error": {
|
||||
"notFoundTitle": "Page not found",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
},
|
||||
"meta": {
|
||||
"homeTitle": "Agent Teams - Orquestación de agentes IA para desarrolladores",
|
||||
"homeDescription": "App de escritorio gratuita y de código abierto para montar equipos de agentes IA. Tablero kanban, revisión de código, comunicación entre equipos. Funciona completamente en local."
|
||||
"homeDescription": "App de escritorio gratuita y de código abierto para montar equipos de agentes IA. Tablero kanban, revisión de código, comunicación entre equipos. Funciona completamente en local.",
|
||||
"downloadTitle": "Descargar Agent Teams para macOS, Windows y Linux",
|
||||
"downloadDescription": "Descarga Agent Teams para macOS, Windows y Linux. App de escritorio gratis y open source para equipos de agentes Claude, Codex y OpenCode."
|
||||
},
|
||||
"error": {
|
||||
"notFoundTitle": "Página no encontrada",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
},
|
||||
"meta": {
|
||||
"homeTitle": "Agent Teams - Orchestration d'agents IA pour développeurs",
|
||||
"homeDescription": "Application desktop gratuite et open source pour gérer des équipes d'agents IA. Tableau kanban, revue de code, communication inter-équipes. Fonctionne entièrement en local."
|
||||
"homeDescription": "Application desktop gratuite et open source pour gérer des équipes d'agents IA. Tableau kanban, revue de code, communication inter-équipes. Fonctionne entièrement en local.",
|
||||
"downloadTitle": "Télécharger Agent Teams pour macOS, Windows et Linux",
|
||||
"downloadDescription": "Téléchargez Agent Teams pour macOS, Windows et Linux. Application desktop gratuite et open source pour équipes d'agents Claude, Codex et OpenCode."
|
||||
},
|
||||
"error": {
|
||||
"notFoundTitle": "Page introuvable",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
},
|
||||
"meta": {
|
||||
"homeTitle": "Agent Teams - डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
|
||||
"homeDescription": "AI एजेंट टीमें बनाने के लिए मुफ़्त, ओपन-सोर्स डेस्कटॉप ऐप। कानबन बोर्ड, कोड रिव्यू, क्रॉस-टीम संचार। पूरी तरह लोकल चलता है।"
|
||||
"homeDescription": "AI एजेंट टीमें बनाने के लिए मुफ़्त, ओपन-सोर्स डेस्कटॉप ऐप। कानबन बोर्ड, कोड रिव्यू, क्रॉस-टीम संचार। पूरी तरह लोकल चलता है।",
|
||||
"downloadTitle": "macOS, Windows और Linux के लिए Agent Teams डाउनलोड करें",
|
||||
"downloadDescription": "macOS, Windows और Linux के लिए Agent Teams डाउनलोड करें। Claude, Codex और OpenCode एजेंट टीमों के लिए मुफ़्त ओपन-सोर्स डेस्कटॉप ऐप।"
|
||||
},
|
||||
"error": {
|
||||
"notFoundTitle": "पेज नहीं मिला",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
},
|
||||
"meta": {
|
||||
"homeTitle": "Agent Teams - 開発者向けAIエージェントオーケストレーション",
|
||||
"homeDescription": "AIエージェントチームを管理する無料のオープンソースデスクトップアプリ。カンバンボード、コードレビュー、チーム間通信。完全にローカルで動作。"
|
||||
"homeDescription": "AIエージェントチームを管理する無料のオープンソースデスクトップアプリ。カンバンボード、コードレビュー、チーム間通信。完全にローカルで動作。",
|
||||
"downloadTitle": "macOS、Windows、Linux向けAgent Teamsをダウンロード",
|
||||
"downloadDescription": "macOS、Windows、Linux向けAgent Teamsをダウンロード。Claude、Codex、OpenCodeエージェントチーム用の無料オープンソースデスクトップアプリ。"
|
||||
},
|
||||
"error": {
|
||||
"notFoundTitle": "ページが見つかりません",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
},
|
||||
"meta": {
|
||||
"homeTitle": "Agent Teams - Orquestração de agentes IA para desenvolvedores",
|
||||
"homeDescription": "App desktop gratuito e open source para montar equipes de agentes IA. Quadro kanban, revisão de código, comunicação entre equipes. Roda totalmente local."
|
||||
"homeDescription": "App desktop gratuito e open source para montar equipes de agentes IA. Quadro kanban, revisão de código, comunicação entre equipes. Roda totalmente local.",
|
||||
"downloadTitle": "Baixar Agent Teams para macOS, Windows e Linux",
|
||||
"downloadDescription": "Baixe o Agent Teams para macOS, Windows e Linux. App desktop gratuito e open source para equipes de agentes Claude, Codex e OpenCode."
|
||||
},
|
||||
"error": {
|
||||
"notFoundTitle": "Página não encontrada",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
},
|
||||
"meta": {
|
||||
"homeTitle": "Agent Teams - Оркестрация ИИ-агентов для разработчиков",
|
||||
"homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально."
|
||||
"homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально.",
|
||||
"downloadTitle": "Скачать Agent Teams для macOS, Windows и Linux",
|
||||
"downloadDescription": "Скачайте Agent Teams для macOS, Windows и Linux. Бесплатное open-source приложение для команд агентов Claude, Codex и OpenCode."
|
||||
},
|
||||
"error": {
|
||||
"notFoundTitle": "Страница не найдена",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@
|
|||
},
|
||||
"meta": {
|
||||
"homeTitle": "Agent Teams - 面向开发者的 AI 智能体编排",
|
||||
"homeDescription": "免费开源桌面应用,用于组建 AI 智能体团队。看板、代码审查、跨团队通信。完全本地运行。"
|
||||
"homeDescription": "免费开源桌面应用,用于组建 AI 智能体团队。看板、代码审查、跨团队通信。完全本地运行。",
|
||||
"downloadTitle": "下载适用于 macOS、Windows 和 Linux 的 Agent Teams",
|
||||
"downloadDescription": "下载适用于 macOS、Windows 和 Linux 的 Agent Teams。面向 Claude、Codex 和 OpenCode 智能体团队的免费开源桌面应用。"
|
||||
},
|
||||
"error": {
|
||||
"notFoundTitle": "页面未找到",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import vuetify from "vite-plugin-vuetify";
|
||||
import { generateI18nRoutes, supportedLocales } from "./data/i18n";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare const process: any;
|
||||
declare const process: {
|
||||
env: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/agent-teams-ai";
|
||||
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/agent-teams-ai";
|
||||
|
|
@ -20,6 +21,7 @@ export default defineNuxtConfig({
|
|||
{ rel: "icon", type: "image/x-icon", href: `${baseURL}favicon.ico` },
|
||||
{ rel: "icon", type: "image/png", sizes: "32x32", href: `${baseURL}favicon-32.png` },
|
||||
{ rel: "apple-touch-icon", sizes: "192x192", href: `${baseURL}logo-192.png` },
|
||||
{ rel: "alternate", type: "text/plain", title: "llms.txt", href: `${baseURL}llms.txt` },
|
||||
{ rel: "dns-prefetch", href: "https://api.github.com" },
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
|
||||
|
|
@ -65,7 +67,8 @@ export default defineNuxtConfig({
|
|||
routes: [
|
||||
...generateI18nRoutes(),
|
||||
"/sitemap.xml",
|
||||
"/robots.txt"
|
||||
"/robots.txt",
|
||||
"/llms.txt"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -76,7 +79,7 @@ export default defineNuxtConfig({
|
|||
},
|
||||
i18n: {
|
||||
restructureDir: false,
|
||||
locales: [...supportedLocales] as any,
|
||||
locales: [...supportedLocales],
|
||||
defaultLocale: "en",
|
||||
strategy: "prefix_except_default",
|
||||
lazy: true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
const { content } = useLandingContent();
|
||||
|
||||
usePageSeo("meta.homeTitle", "meta.homeDescription");
|
||||
usePageSeo("meta.downloadTitle", "meta.downloadDescription");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -114,17 +114,45 @@ export default defineConfig({
|
|||
cleanUrls: true,
|
||||
lastUpdated: true,
|
||||
sitemap: {
|
||||
hostname: docsUrl
|
||||
hostname: docsUrl,
|
||||
lastmodDateOnly: true
|
||||
},
|
||||
head: [
|
||||
["link", { rel: "icon", type: "image/png", href: `${base}logo-192.png` }],
|
||||
["meta", { name: "theme-color", content: "#00f0ff" }],
|
||||
["link", { rel: "canonical", href: docsUrl }],
|
||||
["meta", { name: "robots", content: "index, follow" }],
|
||||
["meta", { name: "author", content: "777genius" }],
|
||||
["meta", { name: "generator", content: "VitePress" }],
|
||||
["meta", { name: "color-scheme", content: "light dark" }],
|
||||
["meta", { name: "theme-color", content: "#f8fafc", media: "(prefers-color-scheme: light)" }],
|
||||
["meta", { name: "theme-color", content: "#0a0a0f", media: "(prefers-color-scheme: dark)" }],
|
||||
["meta", { property: "og:type", content: "website" }],
|
||||
["meta", { property: "og:title", content: SITE_TITLE }],
|
||||
["meta", { property: "og:description", content: SITE_DESCRIPTION }],
|
||||
["meta", { property: "og:url", content: docsUrl }],
|
||||
["meta", { property: "og:image", content: `${publicBaseUrl}og-image.png` }],
|
||||
["meta", { name: "twitter:card", content: "summary_large_image" }]
|
||||
["meta", { property: "og:image:width", content: "1200" }],
|
||||
["meta", { property: "og:image:height", content: "630" }],
|
||||
["meta", { property: "og:site_name", content: "Agent Teams" }],
|
||||
["meta", { property: "og:locale", content: "en_US" }],
|
||||
["meta", { name: "twitter:card", content: "summary_large_image" }],
|
||||
["meta", { name: "twitter:title", content: SITE_TITLE }],
|
||||
["meta", { name: "twitter:description", content: SITE_DESCRIPTION }],
|
||||
["meta", { name: "twitter:image", content: `${publicBaseUrl}og-image.png` }],
|
||||
[
|
||||
"script",
|
||||
{ type: "application/ld+json" },
|
||||
JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "Agent Teams",
|
||||
description: SITE_DESCRIPTION,
|
||||
url: publicBaseUrl,
|
||||
applicationCategory: "DeveloperApplication",
|
||||
operatingSystem: "macOS, Windows, Linux",
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }
|
||||
})
|
||||
]
|
||||
],
|
||||
vite: {
|
||||
publicDir: landingPublicDir,
|
||||
|
|
@ -152,7 +180,18 @@ export default defineConfig({
|
|||
label: "On this page"
|
||||
},
|
||||
search: {
|
||||
provider: "local"
|
||||
provider: "local",
|
||||
options: {
|
||||
translations: {
|
||||
button: "Search...",
|
||||
buttonAriaLabel: "Search documentation",
|
||||
noResultsText: "No results found",
|
||||
suggestedQueryText: "Try searching for",
|
||||
reportMissing: "Found a problem? Create an issue",
|
||||
reportMissingText: "Report missing result",
|
||||
reportMissingLink: "https://github.com/777genius/agent-teams-ai/issues/new"
|
||||
}
|
||||
}
|
||||
},
|
||||
nav: rootNav,
|
||||
sidebar: {
|
||||
|
|
@ -194,6 +233,25 @@ export default defineConfig({
|
|||
level: [2, 3],
|
||||
label: "На этой странице"
|
||||
},
|
||||
search: {
|
||||
provider: "local",
|
||||
options: {
|
||||
translations: {
|
||||
button: {
|
||||
buttonText: "Поиск по документации",
|
||||
buttonAriaLabel: "поиск по документации"
|
||||
},
|
||||
modal: {
|
||||
noResultsText: "Результаты не найдены",
|
||||
footer: {
|
||||
selectText: "для выбора",
|
||||
navigateText: "для навигации",
|
||||
closeText: "для закрытия"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
editLink: {
|
||||
pattern: `https://github.com/${REPO}/edit/main/landing/product-docs/:path`,
|
||||
text: "Редактировать на GitHub"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ const cards = computed(() => {
|
|||
<span class="docs-card__icon">{{ card.icon }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
<span>{{ card.desc }}</span>
|
||||
<span class="docs-card__arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -61,8 +62,10 @@ const cards = computed(() => {
|
|||
}
|
||||
|
||||
.docs-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 12px;
|
||||
row-gap: 4px;
|
||||
|
|
@ -72,24 +75,27 @@ const cards = computed(() => {
|
|||
background: var(--at-c-surface-soft);
|
||||
color: var(--at-c-text);
|
||||
text-decoration: none !important;
|
||||
box-shadow: var(--at-shadow-card);
|
||||
transition:
|
||||
border-color var(--at-transition-base),
|
||||
background-color var(--at-transition-base),
|
||||
transform var(--at-transition-base);
|
||||
transform var(--at-transition-base),
|
||||
box-shadow var(--at-transition-base);
|
||||
}
|
||||
|
||||
.docs-card:hover {
|
||||
border-color: var(--at-c-border-strong);
|
||||
background: var(--at-glass-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--at-shadow-cyan-md);
|
||||
}
|
||||
|
||||
.docs-card__icon {
|
||||
grid-row: 1 / -1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--at-radius-md);
|
||||
background: var(--at-gradient-panel);
|
||||
color: var(--at-c-cyan);
|
||||
|
|
@ -101,17 +107,44 @@ const cards = computed(() => {
|
|||
.docs-card strong {
|
||||
color: var(--at-c-text);
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.docs-card span:last-child {
|
||||
.docs-card > span:nth-of-type(2) {
|
||||
color: var(--at-c-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.docs-card__arrow {
|
||||
grid-column: 3;
|
||||
align-self: end;
|
||||
color: var(--at-c-cyan);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 16px;
|
||||
opacity: 0.55;
|
||||
transform: translateX(-4px);
|
||||
transition:
|
||||
opacity var(--at-transition-base),
|
||||
transform var(--at-transition-base);
|
||||
}
|
||||
|
||||
.docs-card:hover .docs-card__arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.docs-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.docs-card {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.docs-card__arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const workflowVideoSrc = "https://github.com/user-attachments/assets/35e27989-72
|
|||
<source :src="workflowVideoSrc" type="video/mp4">
|
||||
</video>
|
||||
<div class="docs-hero-visual__wash" />
|
||||
<div class="docs-hero-visual__glow" />
|
||||
<div class="docs-hero-visual__edge" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -25,11 +26,23 @@ const workflowVideoSrc = "https://github.com/user-attachments/assets/35e27989-72
|
|||
<style scoped>
|
||||
.docs-hero-visual {
|
||||
position: absolute;
|
||||
inset: -130px -120px -110px;
|
||||
inset: -140px -126px -116px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.docs-hero-visual::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 12% auto auto 8%;
|
||||
width: 34%;
|
||||
height: 38%;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgba(0, 240, 255, 0.24), transparent 72%);
|
||||
filter: blur(12px);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.docs-hero-visual__video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -37,47 +50,57 @@ const workflowVideoSrc = "https://github.com/user-attachments/assets/35e27989-72
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(1px) saturate(1.22) contrast(1.08);
|
||||
opacity: 0.95;
|
||||
filter: blur(0.8px) saturate(1.18) contrast(1.06);
|
||||
opacity: 0.78;
|
||||
mix-blend-mode: multiply;
|
||||
transform: scale(1.04);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.docs-hero-visual__wash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(90deg, var(--vp-c-bg) 0%, color-mix(in srgb, var(--vp-c-bg) 82%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 8%, transparent) 64%, color-mix(in srgb, var(--vp-c-bg) 26%, transparent) 100%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 32%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 44%, transparent) 58%, var(--vp-c-bg) 96%);
|
||||
linear-gradient(90deg, var(--vp-c-bg) 0%, color-mix(in srgb, var(--vp-c-bg) 86%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 10%, transparent) 66%, color-mix(in srgb, var(--vp-c-bg) 28%, transparent) 100%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 42%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 48%, transparent) 58%, var(--vp-c-bg) 96%);
|
||||
}
|
||||
|
||||
.docs-hero-visual__glow {
|
||||
position: absolute;
|
||||
inset: auto -8% 10% auto;
|
||||
width: 44%;
|
||||
height: 32%;
|
||||
background: radial-gradient(circle, rgba(255, 0, 255, 0.2), transparent 70%);
|
||||
filter: blur(18px);
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.docs-hero-visual__edge {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 42%;
|
||||
height: 48%;
|
||||
background: linear-gradient(180deg, transparent, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.dark .docs-hero-visual__video {
|
||||
opacity: 0.95;
|
||||
filter: blur(1px) saturate(1.24) contrast(1.08);
|
||||
opacity: 0.9;
|
||||
filter: blur(0.8px) saturate(1.22) contrast(1.08);
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
.dark .docs-hero-visual__wash {
|
||||
background:
|
||||
linear-gradient(90deg, var(--vp-c-bg) 0%, color-mix(in srgb, var(--vp-c-bg) 76%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 8%, transparent) 64%, color-mix(in srgb, var(--vp-c-bg) 34%, transparent) 100%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 28%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 52%, transparent) 58%, var(--vp-c-bg) 96%);
|
||||
linear-gradient(90deg, var(--vp-c-bg) 0%, color-mix(in srgb, var(--vp-c-bg) 78%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 10%, transparent) 64%, color-mix(in srgb, var(--vp-c-bg) 36%, transparent) 100%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 30%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 58%, transparent) 58%, var(--vp-c-bg) 96%);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.docs-hero-visual {
|
||||
inset: -90px -72px -80px;
|
||||
inset: -78px -58px -64px;
|
||||
}
|
||||
|
||||
.docs-hero-visual__video {
|
||||
opacity: 0.95;
|
||||
filter: blur(1px) saturate(1.2) contrast(1.06);
|
||||
opacity: 0.82;
|
||||
filter: blur(0.8px) saturate(1.16) contrast(1.04);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -37,40 +37,61 @@ async function copy() {
|
|||
.install-block {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 100%;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
gap: 14px;
|
||||
margin: 12px 0 4px;
|
||||
padding: 12px 16px;
|
||||
padding: 14px 16px;
|
||||
border: var(--at-glass-border);
|
||||
border-radius: var(--at-radius-lg);
|
||||
border-radius: var(--at-radius-xl);
|
||||
background: var(--at-c-surface-soft);
|
||||
color: var(--at-c-text);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--at-shadow-card);
|
||||
transition:
|
||||
border-color var(--at-transition-base),
|
||||
background-color var(--at-transition-base),
|
||||
transform var(--at-transition-base);
|
||||
transform var(--at-transition-base),
|
||||
box-shadow var(--at-transition-base);
|
||||
}
|
||||
|
||||
.install-block:hover {
|
||||
border-color: var(--at-c-border-strong);
|
||||
background: var(--at-glass-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--at-shadow-cyan-md);
|
||||
}
|
||||
|
||||
.install-block code {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--at-c-text);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.install-block span {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--at-radius-pill);
|
||||
background: color-mix(in srgb, var(--at-c-cyan) 12%, transparent);
|
||||
color: var(--at-c-cyan);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.install-block {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.install-block span {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ defineProps<{
|
|||
.zoom-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: var(--at-glass-border);
|
||||
border-radius: var(--at-radius-xl);
|
||||
border: var(--at-glass-border);
|
||||
background: var(--at-c-dark-1);
|
||||
box-shadow: var(--at-shadow-card);
|
||||
cursor: zoom-in;
|
||||
|
|
@ -34,6 +34,7 @@ defineProps<{
|
|||
margin-top: 8px;
|
||||
color: var(--at-c-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -52,10 +52,14 @@
|
|||
|
||||
html {
|
||||
background: var(--vp-c-bg);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
letter-spacing: 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-feature-settings: "liga" 1, "calt" 1, "kern" 1;
|
||||
}
|
||||
|
||||
.Layout {
|
||||
|
|
@ -70,6 +74,8 @@ body {
|
|||
z-index: -2;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 18% -8%, rgba(0, 240, 255, 0.16), transparent 34%),
|
||||
radial-gradient(circle at 82% 0%, rgba(255, 0, 255, 0.09), transparent 26%),
|
||||
linear-gradient(180deg, rgba(0, 240, 255, 0.08), transparent 320px),
|
||||
linear-gradient(135deg, rgba(255, 0, 255, 0.055), transparent 42%),
|
||||
var(--vp-c-bg);
|
||||
|
|
@ -82,8 +88,10 @@ body {
|
|||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--vp-c-bg) 72%, transparent) 58%, var(--vp-c-bg) 100%);
|
||||
opacity: 0.8;
|
||||
linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--vp-c-bg) 72%, transparent) 58%, var(--vp-c-bg) 100%),
|
||||
linear-gradient(90deg, transparent 0, transparent calc(100% - 1px), var(--vp-c-divider) calc(100% - 1px), var(--vp-c-divider) 100%);
|
||||
background-size: auto, 72px 72px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.VPNavBar {
|
||||
|
|
@ -91,6 +99,7 @@ body {
|
|||
background: var(--vp-nav-bg-color) !important;
|
||||
backdrop-filter: blur(var(--at-blur-md)) !important;
|
||||
-webkit-backdrop-filter: blur(var(--at-blur-md)) !important;
|
||||
box-shadow: 0 10px 30px -24px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.VPNavBarTitle .logo {
|
||||
|
|
@ -102,6 +111,7 @@ body {
|
|||
.VPNavBarTitle .title {
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
|
|
@ -114,24 +124,33 @@ body {
|
|||
|
||||
.VPHero .text {
|
||||
max-width: 760px;
|
||||
text-wrap: balance;
|
||||
font-size: clamp(2.4rem, 6vw, 4.95rem);
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.VPHero .tagline {
|
||||
max-width: 680px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.VPHero .actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.VPHero.has-image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 560px;
|
||||
padding: 96px 24px 76px;
|
||||
padding: 108px 24px 88px;
|
||||
}
|
||||
|
||||
.VPHero.has-image .container {
|
||||
|
|
@ -145,8 +164,32 @@ body {
|
|||
.VPHero.has-image .main {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 820px !important;
|
||||
padding: 38px 0;
|
||||
max-width: 780px !important;
|
||||
padding: 30px 32px 32px;
|
||||
border: 1px solid color-mix(in srgb, var(--vp-c-border) 72%, transparent);
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg-elv) 74%, transparent), color-mix(in srgb, var(--vp-c-bg-soft) 56%, transparent)),
|
||||
color-mix(in srgb, var(--vp-c-bg-elv) 70%, transparent);
|
||||
box-shadow: var(--at-shadow-cyan-lg);
|
||||
backdrop-filter: blur(var(--at-blur-lg));
|
||||
}
|
||||
|
||||
.VPHero.has-image .main::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at 20% 0%, rgba(0, 240, 255, 0.14), transparent 42%),
|
||||
radial-gradient(circle at 82% 12%, rgba(255, 0, 255, 0.08), transparent 30%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.VPHero.has-image .main > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.VPHero.has-image .image {
|
||||
|
|
@ -220,16 +263,28 @@ body {
|
|||
}
|
||||
|
||||
.VPFeature {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: var(--at-glass-border) !important;
|
||||
border-radius: var(--at-radius-xl) !important;
|
||||
background: var(--vp-c-bg-soft) !important;
|
||||
backdrop-filter: blur(var(--at-blur-sm));
|
||||
box-shadow: var(--at-shadow-card);
|
||||
transition:
|
||||
transform var(--at-transition-base),
|
||||
border-color var(--at-transition-base),
|
||||
box-shadow var(--at-transition-base) !important;
|
||||
}
|
||||
|
||||
.VPFeature::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 240, 255, 0.35), transparent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.VPFeature:hover {
|
||||
border-color: var(--at-c-border-strong) !important;
|
||||
box-shadow: var(--at-shadow-cyan-md);
|
||||
|
|
@ -239,9 +294,11 @@ body {
|
|||
.VPFeature .box {
|
||||
display: grid !important;
|
||||
grid-template-columns: 42px 1fr !important;
|
||||
grid-template-rows: auto auto !important;
|
||||
grid-template-rows: auto auto auto !important;
|
||||
column-gap: 12px !important;
|
||||
align-items: center !important;
|
||||
min-height: 100%;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.VPFeature .box > .icon {
|
||||
|
|
@ -249,8 +306,8 @@ body {
|
|||
grid-column: 1 !important;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 0 !important;
|
||||
border-radius: var(--at-radius-md);
|
||||
background: var(--at-gradient-panel);
|
||||
|
|
@ -262,17 +319,20 @@ body {
|
|||
.VPFeature .box > .title {
|
||||
grid-row: 1 !important;
|
||||
grid-column: 2 !important;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.VPFeature .box > .details {
|
||||
grid-row: 2 !important;
|
||||
grid-column: 1 / -1 !important;
|
||||
margin-top: 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.VPFeature .box > .link-text {
|
||||
grid-row: 3 !important;
|
||||
grid-column: 1 / -1 !important;
|
||||
margin-top: 8px !important;
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
|
||||
.VPFeature .box > .link-text .link-text-value {
|
||||
|
|
@ -295,11 +355,14 @@ body {
|
|||
.vp-doc h2,
|
||||
.vp-doc h3 {
|
||||
letter-spacing: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.vp-doc h1 {
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 800;
|
||||
font-size: clamp(2rem, 3.5vw, 3.25rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.dark .vp-doc h1 {
|
||||
|
|
@ -311,11 +374,17 @@ body {
|
|||
|
||||
.vp-doc p,
|
||||
.vp-doc li {
|
||||
line-height: 1.75;
|
||||
line-height: 1.8;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.vp-doc a {
|
||||
text-underline-offset: 4px;
|
||||
transition: color var(--at-transition-fast);
|
||||
}
|
||||
|
||||
.vp-doc a:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.VPNavBarMenuLink[target="_self"].vp-external-link-icon::after,
|
||||
|
|
@ -330,6 +399,14 @@ body {
|
|||
border-radius: var(--at-radius-xl);
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg-soft);
|
||||
box-shadow: var(--at-shadow-card);
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.vp-doc th,
|
||||
.vp-doc td {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.vp-doc th {
|
||||
|
|
@ -339,6 +416,14 @@ body {
|
|||
background: rgba(0, 240, 255, 0.05);
|
||||
}
|
||||
|
||||
.vp-doc tbody tr:nth-child(2n) {
|
||||
background: color-mix(in srgb, var(--vp-c-bg) 92%, transparent);
|
||||
}
|
||||
|
||||
.vp-doc tbody tr:hover {
|
||||
background: color-mix(in srgb, var(--vp-c-brand-soft) 30%, transparent);
|
||||
}
|
||||
|
||||
:root:not(.dark) .vp-doc th,
|
||||
:root:not(.dark) .docs-card__icon,
|
||||
:root:not(.dark) .install-block span {
|
||||
|
|
@ -353,17 +438,31 @@ body {
|
|||
.vp-doc :not(pre) > code {
|
||||
border: 1px solid rgba(0, 240, 255, 0.1);
|
||||
border-radius: var(--at-radius-xs);
|
||||
padding: 0.15em 0.38em;
|
||||
background: color-mix(in srgb, var(--vp-c-brand-soft) 28%, transparent);
|
||||
color: var(--vp-code-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"] {
|
||||
border: 1px solid rgba(0, 240, 255, 0.12);
|
||||
border: 1px solid color-mix(in srgb, var(--vp-c-border) 86%, transparent);
|
||||
border-radius: var(--at-radius-xl);
|
||||
box-shadow: var(--at-shadow-card);
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg-elv) 84%, transparent), color-mix(in srgb, var(--vp-c-bg-soft) 72%, transparent)),
|
||||
var(--vp-code-block-bg);
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"] pre,
|
||||
.vp-doc div[class*="language-"] code {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.vp-doc .custom-block {
|
||||
border-radius: var(--at-radius-xl);
|
||||
box-shadow: var(--at-shadow-card);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.tip {
|
||||
|
|
@ -401,21 +500,58 @@ body {
|
|||
z-index: 9999;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.VPHero.has-image .main {
|
||||
padding: 26px 24px 26px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.VPHero.has-image {
|
||||
min-height: 520px;
|
||||
padding: 72px 20px 56px;
|
||||
min-height: 0;
|
||||
padding: 68px 16px 44px;
|
||||
}
|
||||
|
||||
.VPHero.has-image .main {
|
||||
padding: 24px 0;
|
||||
padding: 24px 18px 22px;
|
||||
}
|
||||
|
||||
.VPFeature .box {
|
||||
grid-template-columns: 36px 1fr !important;
|
||||
grid-template-columns: 38px 1fr !important;
|
||||
}
|
||||
|
||||
.VPFeature .box > .icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.VPHero .text {
|
||||
font-size: 34px;
|
||||
font-size: clamp(2rem, 10vw, 3.25rem);
|
||||
}
|
||||
|
||||
.VPHero .tagline {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.VPHero .actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.VPHero .actions .VPButton {
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.vp-doc th,
|
||||
.vp-doc td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"] pre,
|
||||
.vp-doc div[class*="language-"] code {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Agent Workflow – Agent Teams Docs
|
||||
description: Understand task lifecycle, kanban board, messages, task logs, parallel work, live processes, and cross-team communication.
|
||||
---
|
||||
|
||||
# Agent Workflow
|
||||
|
||||
Agent Teams makes agent work visible as task state, messages, logs, and reviewable code changes.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Code Review – Agent Teams Docs
|
||||
description: Inspect task-scoped diffs, accept or reject hunks, leave inline comments, and manage review states from none to approved.
|
||||
---
|
||||
|
||||
# Code Review
|
||||
|
||||
Code review in Agent Teams is task-centered. You inspect what changed for a specific task instead of hunting through a large unstructured diff.
|
||||
|
|
@ -19,6 +24,15 @@ Accept small correct changes and reject isolated mistakes without throwing away
|
|||
If a diff is mostly correct, accept the good hunks first and request changes only for the parts that need fixing. This keeps the board moving.
|
||||
:::
|
||||
|
||||
Use hunk-level decisions for:
|
||||
|
||||
| Situation | Action |
|
||||
| --- | --- |
|
||||
| Correct scoped change | Accept the hunk |
|
||||
| Correct idea, wrong file or broad refactor | Reject the hunk and request a narrower fix |
|
||||
| Unclear behavior change | Comment and ask for verification |
|
||||
| Generated formatting noise | Reject unless formatting was part of the task |
|
||||
|
||||
## Initiating review
|
||||
|
||||
1. Open a completed task
|
||||
|
|
@ -27,6 +41,22 @@ If a diff is mostly correct, accept the good hunks first and request changes onl
|
|||
|
||||
During review the task is not yet considered done, so other teammates or the lead can still comment on it.
|
||||
|
||||
## Review loop
|
||||
|
||||
A healthy review loop looks like this:
|
||||
|
||||
1. The owner posts a result comment with changed scope and verification
|
||||
2. The reviewer opens the task diff and checks hunks against the task description
|
||||
3. The reviewer accepts good hunks, rejects bad hunks, or requests changes
|
||||
4. The owner fixes only the requested scope and posts a follow-up comment
|
||||
5. The reviewer approves when the task result and diff match
|
||||
|
||||
Example request-changes comment:
|
||||
|
||||
```text
|
||||
Please keep the copy improvements, but revert the unrelated runtime wording in the provider table. Add a docs build result before resubmitting.
|
||||
```
|
||||
|
||||
## Review states
|
||||
|
||||
| State | Meaning |
|
||||
|
|
@ -40,6 +70,8 @@ During review the task is not yet considered done, so other teammates or the lea
|
|||
|
||||
Teams can review each other's work before you make the final call. This catches obvious regressions and keeps the board honest, but you should still review risky areas yourself.
|
||||
|
||||
Agent review is most useful when the reviewer has a clear rubric. For example, tell a reviewer to check only docs clarity, only IPC safety, or only test coverage. Broad "review everything" requests tend to produce weaker feedback.
|
||||
|
||||
## Review participants
|
||||
|
||||
The team lead is the default reviewer. You can configure additional reviewers in the Kanban settings if you want peers to review each other's work.
|
||||
|
|
@ -58,6 +90,18 @@ Prioritize these areas when reviewing:
|
|||
|
||||
Prefer focused verification commands. Broad formatting or lint-fix commands should not be used unless the task explicitly intends broad formatting churn.
|
||||
|
||||
Good verification comments include the command and result:
|
||||
|
||||
```text
|
||||
Verified with `pnpm --dir landing docs:build`. Build passed.
|
||||
```
|
||||
|
||||
When verification is skipped, the task comment should say why:
|
||||
|
||||
```text
|
||||
Docs-only wording change. Build not run because the existing dev server was busy; checked Markdown links manually.
|
||||
```
|
||||
|
||||
::: warning Do not auto-format across the whole project
|
||||
Unless the task is specifically about formatting, avoid running `pnpm lint:fix` on unrelated files. It creates noise in the review surface.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Create a Team – Agent Teams Docs
|
||||
description: Define roles, assign providers and models, write a team brief, and configure worktree isolation and autonomy levels.
|
||||
---
|
||||
|
||||
# Create a Team
|
||||
|
||||
A team is a named group of agents with roles, a lead, a target project, and a coordination prompt.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Installation – Agent Teams Docs
|
||||
description: Download and install Agent Teams for macOS, Windows, or Linux. Covers packaged builds, source setup, auto-updates, and requirements.
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Quickstart – Agent Teams Docs
|
||||
description: Get from a fresh install to a running AI agent team in a few minutes. Covers installation, runtime selection, team creation, and first code review.
|
||||
---
|
||||
|
||||
# Quickstart
|
||||
|
||||
This guide gets you from a fresh install to a running team in a few minutes.
|
||||
|
|
@ -18,6 +23,14 @@ Launch the app and select the project directory you want agents to work in. Agen
|
|||
Pick a Git-tracked project for the best experience. Worktree isolation and diff-based review both rely on Git.
|
||||
:::
|
||||
|
||||
Before launching a team, check that the project has a clean enough baseline:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
You do not need a perfectly clean tree, but you should know which changes are yours before agents start editing. This makes task diffs and hunk-level review much easier to trust.
|
||||
|
||||
## 3. Choose a runtime path
|
||||
|
||||
The setup flow auto-detects installed runtimes on your machine. A common first setup is:
|
||||
|
|
@ -34,12 +47,32 @@ Gemini support is in development and will appear in the runtime list when availa
|
|||
|
||||
See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider.
|
||||
|
||||
To verify the selected runtime outside the app, run the matching version command:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
codex --version
|
||||
opencode --version
|
||||
```
|
||||
|
||||
If the command fails in your terminal, fix the runtime installation or `PATH` first. Team prompts cannot work around a missing binary or missing provider auth.
|
||||
|
||||
## 4. Create your first team
|
||||
|
||||
Create a team with a lead and one or more specialists. Keep the first team small: one lead, one implementation agent, and one review-oriented agent is enough to validate the workflow.
|
||||
|
||||
See [Create a team](/guide/create-team) for the recommended structure and tips.
|
||||
|
||||
For the first launch, prefer a team shape like this:
|
||||
|
||||
| Member | Responsibility | Notes |
|
||||
| --- | --- | --- |
|
||||
| Lead | Split the goal into tasks and coordinate status | Keep on the most reliable provider you have |
|
||||
| Builder | Implement scoped tasks | Give clear file or feature boundaries |
|
||||
| Reviewer | Review completed work | Ask it to focus on regressions and missing tests |
|
||||
|
||||
Avoid starting with five or more teammates. More agents increase concurrency, logs, provider usage, and conflict risk before you know the setup is healthy.
|
||||
|
||||
## 5. Give the lead a concrete goal
|
||||
|
||||
Write the goal like you would brief an engineering lead:
|
||||
|
|
@ -48,6 +81,14 @@ Write the goal like you would brief an engineering lead:
|
|||
Improve the onboarding flow. Split the work into tasks, keep changes small, and ask for review before broad refactors.
|
||||
```
|
||||
|
||||
Good first prompts include concrete scope, safety boundaries, and verification:
|
||||
|
||||
```text
|
||||
Improve the docs quickstart. Keep edits inside landing/product-docs. Add practical examples, preserve existing VitePress syntax, and run the docs build before marking tasks done.
|
||||
```
|
||||
|
||||
Avoid vague prompts such as "make the app better" for the first run. The lead can break down large goals, but better input produces smaller tasks and cleaner review.
|
||||
|
||||
The lead creates tasks, assigns work, and coordinates teammates. You can watch progress on the kanban board and intervene with comments or direct messages at any time.
|
||||
|
||||
## 6. Review results
|
||||
|
|
@ -56,6 +97,12 @@ Open completed or review-ready tasks, inspect the diff, and accept, reject, or c
|
|||
|
||||
See [Code review](/guide/code-review) for the full review workflow.
|
||||
|
||||
Before approving the first task, check three things:
|
||||
|
||||
1. The task comment explains what changed
|
||||
2. The changed files match the task scope
|
||||
3. The verification result is visible in the task comment or logs
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Create a team](/guide/create-team) — recommended team shapes and brief writing
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
---
|
||||
title: Runtime Setup
|
||||
description: Configure Claude Code, Codex, or OpenCode runtimes and provider authentication for agent teams.
|
||||
---
|
||||
|
||||
---
|
||||
title: Runtime Setup – Agent Teams Docs
|
||||
description: Configure Claude Code, Codex, or OpenCode runtimes. Covers auth, provider access, multimodel mode, and prelaunch checks.
|
||||
---
|
||||
|
||||
# Runtime Setup
|
||||
|
||||
Agent Teams is a coordination layer. The actual model work runs through supported local runtimes and providers.
|
||||
|
|
@ -9,11 +19,22 @@ Before launching a team, make sure:
|
|||
- The runtime binary is installed and on your `PATH`.
|
||||
- Your provider account has active access to the model you intend to use.
|
||||
- The project path exists and is readable.
|
||||
- The app and your terminal use the same home/config environment when you test auth manually.
|
||||
|
||||
::: tip
|
||||
Start with a single teammate and one provider. Confirm one launch works before adding multimodel lanes.
|
||||
:::
|
||||
|
||||
Quick terminal checks:
|
||||
|
||||
```bash
|
||||
command -v claude
|
||||
command -v codex
|
||||
command -v opencode
|
||||
```
|
||||
|
||||
Run the command for the runtime you plan to use. If it prints nothing, install the runtime or fix `PATH` before launching a team.
|
||||
|
||||
## Supported paths
|
||||
|
||||
| Path | Default CLI | Typical providers | Use when |
|
||||
|
|
@ -24,6 +45,8 @@ Start with a single teammate and one provider. Confirm one launch works before a
|
|||
|
||||
The app detects supported runtimes and guides setup from the UI when possible.
|
||||
|
||||
Gemini appears in some internal provider lists but is currently hidden from the main team creation UI while its launch experience is still marked in development.
|
||||
|
||||
## Provider access
|
||||
|
||||
Agent Teams has no paid tier of its own. You bring the provider access you already have: subscriptions, local runtime auth, or API keys depending on the path you choose.
|
||||
|
|
@ -47,6 +70,8 @@ Then verify the CLI is reachable:
|
|||
claude --version
|
||||
```
|
||||
|
||||
If the packaged app reports "not logged in" while your terminal works, compare the `$HOME` and `PATH` seen by the app with the terminal you used for login. The auth diagnostic log described in [Troubleshooting](/guide/troubleshooting#auth-diagnostic-log) is the best starting point.
|
||||
|
||||
### Codex
|
||||
|
||||
Install and authenticate via OpenAI's CLI flow:
|
||||
|
|
@ -55,6 +80,14 @@ Install and authenticate via OpenAI's CLI flow:
|
|||
codex login
|
||||
```
|
||||
|
||||
Then verify the runtime is reachable:
|
||||
|
||||
```bash
|
||||
codex --version
|
||||
```
|
||||
|
||||
Codex-native launches use Codex account state and model catalog data when available. If a model is missing from the UI, refresh provider status before editing team prompts.
|
||||
|
||||
### OpenCode
|
||||
|
||||
Create or edit `~/.opencode/config.json` (or the equivalent path on your platform) with the provider key you want:
|
||||
|
|
@ -71,6 +104,16 @@ Create or edit `~/.opencode/config.json` (or the equivalent path on your platfor
|
|||
|
||||
Use the exact provider name that OpenCode expects. If you set a custom provider name, double-check it against the provider ID you use in the model string (for example `openrouter/moonshotai/kimi-k2.6` would use the `openrouter` block).
|
||||
|
||||
Example model strings:
|
||||
|
||||
| Model string | Provider block that must exist |
|
||||
| --- | --- |
|
||||
| `openrouter/moonshotai/kimi-k2.6` | `openrouter` |
|
||||
| `openai/gpt-5.4` | `openai` |
|
||||
| `anthropic/claude-sonnet-4-6` | `anthropic` |
|
||||
|
||||
If OpenCode launches but a teammate never becomes deliverable, inspect lane evidence before assuming the model ignored the prompt. See [Troubleshooting](/guide/troubleshooting#opencode-registered-but-bootstrap-unconfirmed).
|
||||
|
||||
## Multimodel mode
|
||||
|
||||
Multimodel mode can route work through many provider backends via OpenCode-compatible configuration. Use it when you need provider flexibility or want teammates to use different model lanes.
|
||||
|
|
@ -79,6 +122,16 @@ Multimodel mode can route work through many provider backends via OpenCode-compa
|
|||
Each teammate can use a different `providerId` + `model` pair. In the team edit UI, expand member options to override the global defaults.
|
||||
:::
|
||||
|
||||
A conservative multimodel setup:
|
||||
|
||||
| Role | Provider | Why |
|
||||
| --- | --- | --- |
|
||||
| Lead | Claude or Codex | Keep coordination on the provider you trust most |
|
||||
| Builder | OpenCode | Use broad model routing for implementation work |
|
||||
| Reviewer | Claude, Codex, or a second OpenCode model | Separate review judgment from the builder lane |
|
||||
|
||||
Avoid mixing many unfamiliar providers in the first launch. Confirm one small task per lane before assigning broad work.
|
||||
|
||||
## Prelaunch checklist
|
||||
|
||||
Before launching a team:
|
||||
|
|
@ -96,3 +149,13 @@ Switch when the current path is blocked by model availability, rate limits, prov
|
|||
::: warning Treat setup errors as setup problems
|
||||
If auth fails, a model name is rejected, or the runtime binary cannot be found, fix the setup first. Do not change team prompts or project code to work around a runtime configuration issue.
|
||||
:::
|
||||
|
||||
Use this decision table:
|
||||
|
||||
| Symptom | Better first action |
|
||||
| --- | --- |
|
||||
| Binary not found | Fix installation or `PATH` |
|
||||
| Login works in terminal but not app | Check Electron auth diagnostic log and environment |
|
||||
| Model rejected | Verify exact model id in the provider runtime |
|
||||
| Repeated 429s | Lower concurrency or switch model/provider |
|
||||
| OpenCode lane stuck | Inspect lane manifest and `opencode-sessions.json` |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
---
|
||||
title: Troubleshooting
|
||||
description: Fix launch failures, missing agent replies, rate limits, auth issues, and lane bootstrap problems in Agent Teams.
|
||||
---
|
||||
|
||||
---
|
||||
title: Troubleshooting – Agent Teams Docs
|
||||
description: Fix team launch issues, missing agent replies, rate limits, CLI auth problems, and lane bootstrap stalls with local diagnostics.
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, and provider limits.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
---
|
||||
title: Agent Teams Docs – Run AI Agent Teams from a Local Desktop App
|
||||
description: Documentation for Agent Teams, a free desktop app for AI agent orchestration. Create teams, watch work on a kanban board, review code changes, and coordinate Claude, Codex, OpenCode, and multimodel workflows.
|
||||
layout: home
|
||||
hero:
|
||||
name: Agent Teams Docs
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Concepts
|
||||
description: Core vocabulary for Agent Teams — teams, leads, teammates, tasks, kanban, inboxes, runtimes, and review.
|
||||
---
|
||||
|
||||
# Concepts
|
||||
|
||||
This page defines the core terms used across Agent Teams. Use it as the shared vocabulary for the app, task board, messages, and review flow.
|
||||
|
|
@ -6,16 +11,20 @@ This page defines the core terms used across Agent Teams. Use it as the shared v
|
|||
|
||||
A team is a named group of agents attached to one project path. It has a lead, optional teammates, runtime/provider settings, prompts, inboxes, tasks, and local launch state.
|
||||
|
||||
## Lead
|
||||
## Lead {#lead}
|
||||
|
||||
The lead is the coordinator for the team. It turns a user goal into tasks, assigns or redirects teammates, tracks blockers, asks for review, and keeps work moving through the board.
|
||||
|
||||
[Teammate →](#teammate)
|
||||
|
||||
Lead messages use a different delivery path from teammate messages: the app relays lead inbox entries into the lead runtime, while teammates read their own inbox files between turns.
|
||||
|
||||
## Teammate
|
||||
## Teammate {#teammate}
|
||||
|
||||
A teammate is a non-lead agent in the team. Teammates usually own focused roles such as builder, reviewer, researcher, or tester. A teammate can receive direct messages, task assignments, task comments, and review requests.
|
||||
|
||||
[Lead ↑](#lead)
|
||||
|
||||
## Task
|
||||
|
||||
A task is the durable unit of work. It has an id, status, owner, description, comments, logs, attachments, task references, and reviewable changes.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,18 @@
|
|||
---
|
||||
title: FAQ
|
||||
description: Frequently asked questions about Agent Teams — pricing, model access, runtimes, privacy, review, and troubleshooting.
|
||||
---
|
||||
|
||||
---
|
||||
title: FAQ – Agent Teams Docs
|
||||
description: Frequently asked questions about pricing, model access, runtime setup, data privacy, worktree isolation, and code review.
|
||||
---
|
||||
|
||||
---
|
||||
title: FAQ – Agent Teams Docs
|
||||
description: Frequently asked questions about Agent Teams — pricing, model access, runtimes, privacy, review, and debugging.
|
||||
---
|
||||
|
||||
# FAQ
|
||||
|
||||
## Is Agent Teams free?
|
||||
|
|
@ -18,6 +33,18 @@ Not always. The app guides runtime detection and setup from the UI. Some paths s
|
|||
|
||||
OpenCode setup is separate from Claude Code and Codex setup. If a launch fails, check runtime status and provider auth before changing the team prompt.
|
||||
|
||||
## How do I check whether a runtime is ready?
|
||||
|
||||
Run the runtime command in a terminal first:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
codex --version
|
||||
opencode --version
|
||||
```
|
||||
|
||||
Then confirm provider auth for the path you selected. If the command or auth check fails outside Agent Teams, fix setup before launching a team.
|
||||
|
||||
## Does it upload my code to Agent Teams servers?
|
||||
|
||||
No. Agent Teams is not a cloud code-sync service. Provider-backed model calls may receive prompt context depending on your selected runtime.
|
||||
|
|
@ -34,6 +61,14 @@ Prompt context, selected file contents, tool results, command output, task text,
|
|||
|
||||
Yes. Agents can message teammates, comment on tasks, coordinate across teams, and use task references to keep conversations attached to work.
|
||||
|
||||
## What should I put in the first team prompt?
|
||||
|
||||
Give the lead a concrete outcome, file or feature boundaries, risk limits, and verification expectations. For example:
|
||||
|
||||
```text
|
||||
Improve the docs quickstart. Keep edits inside landing/product-docs, add practical examples, and run the docs build before marking work done.
|
||||
```
|
||||
|
||||
## Can I review code before accepting it?
|
||||
|
||||
Yes. The review flow is built around task-scoped diffs and hunk-level decisions.
|
||||
|
|
@ -46,6 +81,10 @@ An Agent Block is hidden agent-only text wrapped in markers such as `<info_for_a
|
|||
|
||||
Solo mode is a one-agent team. It is useful for smaller tasks and lower coordination overhead.
|
||||
|
||||
## Should I use worktree isolation?
|
||||
|
||||
Use it when multiple OpenCode teammates may edit the same Git project in parallel. It reduces file conflicts, but it requires a Git-tracked project and currently applies to OpenCode members.
|
||||
|
||||
## Can different teammates use different providers?
|
||||
|
||||
Yes, provider/model settings can be carried per team member when the selected runtime path supports them. OpenCode is the main path for broad multi-provider routing.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,18 @@
|
|||
---
|
||||
title: Privacy and Local Data
|
||||
description: What the Agent Teams desktop app stores locally and what data may leave your machine through provider-backed models.
|
||||
---
|
||||
|
||||
---
|
||||
title: Privacy and Local Data – Agent Teams Docs
|
||||
description: What Agent Teams stores locally, what may leave your machine through provider-backed model calls, and practical privacy guidance.
|
||||
---
|
||||
|
||||
---
|
||||
title: Privacy and Local Data – Agent Teams Docs
|
||||
description: What the Agent Teams desktop app stores locally and what data may leave your machine through provider-backed model calls.
|
||||
---
|
||||
|
||||
# Privacy and Local Data
|
||||
|
||||
Agent Teams is local-first, but the selected runtime/provider path still matters. This page describes what the desktop app stores locally and what may leave your machine when agents call provider-backed models.
|
||||
|
|
@ -33,6 +48,15 @@ However, when an agent asks a provider-backed model to work, prompt context, sel
|
|||
|
||||
Provider authentication, provider-side retention, training, logging, regional processing, and billing are governed by the provider/runtime you choose. Review those policies for sensitive projects.
|
||||
|
||||
Common examples:
|
||||
|
||||
| Action | Data that may be sent through the runtime/provider |
|
||||
| --- | --- |
|
||||
| Asking an agent to edit a file | The task prompt, relevant file contents, tool results, and command output |
|
||||
| Attaching a screenshot | The attachment content and surrounding task/comment text |
|
||||
| Asking for a code review | Diff context, selected files, comments, and verification logs |
|
||||
| Debugging a failing command | Error output, stack traces, and referenced source snippets |
|
||||
|
||||
## What the app does not guarantee
|
||||
|
||||
- It cannot guarantee that provider-backed model calls never receive private code.
|
||||
|
|
@ -51,6 +75,14 @@ Provider authentication, provider-side retention, training, logging, regional pr
|
|||
- Check generated prompts, task descriptions, and attached files before asking agents to work on confidential material.
|
||||
- Use provider/model paths that match your privacy requirements.
|
||||
|
||||
Before using Agent Teams on a sensitive repository:
|
||||
|
||||
1. Remove secrets from the working tree and task attachments
|
||||
2. Choose the runtime/provider path you are allowed to use
|
||||
3. Start with low autonomy and small tasks
|
||||
4. Review task prompts and generated comments before expanding scope
|
||||
5. Keep logs local unless you intentionally share them for support
|
||||
|
||||
## Open source model
|
||||
|
||||
The app itself is open source and free. You can inspect how local orchestration, task tracking, inboxes, runtime diagnostics, and review flows work in the repository.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,18 @@
|
|||
---
|
||||
title: Providers and Runtimes
|
||||
description: Supported runtime paths, provider ids, model ids, multi-provider strategy, and capability checks in Agent Teams.
|
||||
---
|
||||
|
||||
---
|
||||
title: Providers and Runtimes – Agent Teams Docs
|
||||
description: Supported runtime paths (Claude Code, Codex, OpenCode), provider IDs, model naming, multi-provider strategies, and capability checks.
|
||||
---
|
||||
|
||||
---
|
||||
title: Providers and Runtimes – Agent Teams Docs
|
||||
description: Supported runtime paths, provider ids, model ids, multi-provider strategy, and capability checks in Agent Teams.
|
||||
---
|
||||
|
||||
# Providers and Runtimes
|
||||
|
||||
Agent Teams separates orchestration from model access. The app manages teams, tasks, messages, launch state, and review UI; the selected runtime/provider path performs the actual model work.
|
||||
|
|
@ -33,6 +48,8 @@ The runtime provides:
|
|||
| Codex | Codex / OpenAI-backed models | Codex-native workflows | Uses Codex runtime integration and Codex auth/account state where available. Some diagnostics are different from Claude transcripts. |
|
||||
| OpenCode | OpenCode-managed model routing | Multi-provider teams and broad model coverage | OpenCode can route through many model providers. Agent Teams treats OpenCode lanes as runtime-specific evidence and avoids guessing when lane identity is ambiguous. |
|
||||
|
||||
Gemini provider ids exist in internal configuration paths, but Gemini is currently hidden from the main team creation UI while the launch flow remains in development.
|
||||
|
||||
## Provider ids
|
||||
|
||||
The app currently recognizes these provider ids in team/runtime configuration:
|
||||
|
|
@ -46,6 +63,20 @@ The app currently recognizes these provider ids in team/runtime configuration:
|
|||
|
||||
Do not read this table as a guarantee that every provider is authenticated, installed, or available for every model on every machine. The runtime status and capability checks are the source of truth for a given launch.
|
||||
|
||||
## Model ids
|
||||
|
||||
Model ids are passed to the selected runtime. Agent Teams does not rewrite a provider's model catalog into a universal naming scheme.
|
||||
|
||||
Examples:
|
||||
|
||||
| Provider path | Example model id | Notes |
|
||||
| --- | --- | --- |
|
||||
| Claude Code | `opus`, `sonnet`, or a full Claude model id | Availability depends on Claude Code and account access |
|
||||
| Codex | `gpt-5.4`, `gpt-5.3-codex` | Availability comes from Codex account/runtime state |
|
||||
| OpenCode | `openrouter/moonshotai/kimi-k2.6` | Prefix must match an OpenCode provider configuration |
|
||||
|
||||
If a model name is rejected, verify it directly in the runtime/provider first. Changing a team brief cannot make an unavailable model launch.
|
||||
|
||||
## Multi-provider strategy
|
||||
|
||||
Agent Teams keeps orchestration provider-aware but not provider-owned:
|
||||
|
|
@ -55,6 +86,14 @@ Agent Teams keeps orchestration provider-aware but not provider-owned:
|
|||
- model availability, auth, rate limits, and tool behavior remain runtime/provider responsibilities
|
||||
- OpenCode is the broadest routing path when you want one team to use multiple provider/model lanes
|
||||
|
||||
Recommended patterns:
|
||||
|
||||
| Pattern | When it helps | Risk |
|
||||
| --- | --- | --- |
|
||||
| One provider for all members | First launch, sensitive repos, simplest debugging | Shared rate limits can stop the whole team |
|
||||
| Strong lead + cheaper builders | Keep planning/review reliable while reducing implementation cost | Builder output may need stricter review |
|
||||
| Separate builder and reviewer models | Catch model-specific blind spots | More setup and attribution to inspect |
|
||||
|
||||
## Provider costs
|
||||
|
||||
Agent Teams is free and open source. Provider usage is governed by the runtime/provider you select: subscription limits, API keys, account auth, rate limits, and provider policies all remain external to the app.
|
||||
|
|
@ -65,6 +104,15 @@ During setup, the app may perform access and capability checks. This helps detec
|
|||
|
||||
Capability checks can report that a provider exists but is not authenticated, that a model list is unavailable, that a runtime path is missing, or that a specific extension capability is unsupported. Treat those results as setup diagnostics, not task failures.
|
||||
|
||||
Typical setup fixes:
|
||||
|
||||
| Check result | What to do |
|
||||
| --- | --- |
|
||||
| Runtime missing | Install the CLI or fix `PATH` |
|
||||
| Provider unauthenticated | Run the provider login flow or add the required API key |
|
||||
| Model unavailable | Pick a model visible in that runtime's model list |
|
||||
| Capability unsupported | Use another runtime path for that teammate |
|
||||
|
||||
## Limits to expect
|
||||
|
||||
- Runtime support does not mean equal feature parity across Claude Code, Codex, and OpenCode.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
---
|
||||
title: Работа агентов – Документация Agent Teams
|
||||
description: Жизненный цикл задач, канбан-доска, сообщения, task logs, параллельная работа, live processes и cross-team communication.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Работа агентов
|
||||
|
||||
Agent Teams делает работу агентов видимой через task state, messages, logs и reviewable code changes.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
---
|
||||
title: Код-ревью – Документация Agent Teams
|
||||
description: Проверять diff по задаче, принимать или отклонять hunks, оставлять inline comments и управлять review states от none до approved.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Код-ревью
|
||||
|
||||
Код-ревью в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff.
|
||||
|
|
@ -19,6 +25,15 @@
|
|||
Если diff в основном верен, сначала примите хорошие hunks и запросите правки только для проблемных частей. Это не даёт доске застопориться.
|
||||
:::
|
||||
|
||||
Используйте hunk-level decisions так:
|
||||
|
||||
| Situation | Action |
|
||||
| --- | --- |
|
||||
| Correct scoped change | Accept hunk |
|
||||
| Correct idea, wrong file или broad refactor | Reject hunk и request narrower fix |
|
||||
| Unclear behavior change | Comment и попросить verification |
|
||||
| Generated formatting noise | Reject, если formatting не был частью task |
|
||||
|
||||
## Инициирование ревью
|
||||
|
||||
1. Откройте завершённую задачу
|
||||
|
|
@ -27,6 +42,22 @@
|
|||
|
||||
Во время ревью задача ещё не считается завершённой, поэтому другие teammates или lead могут всё ещё комментировать её.
|
||||
|
||||
## Review loop
|
||||
|
||||
Здоровый review loop выглядит так:
|
||||
|
||||
1. Owner публикует result comment с changed scope и verification
|
||||
2. Reviewer открывает task diff и сверяет hunks с task description
|
||||
3. Reviewer принимает хорошие hunks, отклоняет плохие или requests changes
|
||||
4. Owner исправляет только requested scope и пишет follow-up comment
|
||||
5. Reviewer approves, когда task result и diff совпадают
|
||||
|
||||
Пример request-changes comment:
|
||||
|
||||
```text
|
||||
Please keep the copy improvements, but revert the unrelated runtime wording in the provider table. Add a docs build result before resubmitting.
|
||||
```
|
||||
|
||||
## Состояния ревью
|
||||
|
||||
| Состояние | Значение |
|
||||
|
|
@ -40,6 +71,8 @@
|
|||
|
||||
Команды могут ревьюить работу друг друга до вашего финального решения. Это ловит очевидные регрессии, но risky areas всё равно стоит проверять вручную.
|
||||
|
||||
Agent review полезнее, когда reviewer получает ясный rubric. Например, попросите проверить только docs clarity, только IPC safety или только test coverage. Широкие запросы "review everything" обычно дают более слабый feedback.
|
||||
|
||||
## Участники ревью
|
||||
|
||||
Team lead — ревьюер по умолчанию. Вы можете настроить дополнительных ревьюеров в настройках Kanban, если хотите, чтобы peers ревьюили работу друг друга.
|
||||
|
|
@ -58,6 +91,18 @@ Team lead — ревьюер по умолчанию. Вы можете наст
|
|||
|
||||
Лучше запускать focused verification commands. Broad formatting или lint-fix команды не стоит использовать, если задача явно не про форматирование.
|
||||
|
||||
Хорошие verification comments включают command и result:
|
||||
|
||||
```text
|
||||
Verified with `pnpm --dir landing docs:build`. Build passed.
|
||||
```
|
||||
|
||||
Если verification пропущена, task comment должен объяснять почему:
|
||||
|
||||
```text
|
||||
Docs-only wording change. Build not run because the existing dev server was busy; checked Markdown links manually.
|
||||
```
|
||||
|
||||
::: warning Не запускайте автоформатирование по всему проекту
|
||||
Если задача не специфически про форматирование, избегайте `pnpm lint:fix` на несвязанных файлах. Это создаёт шум в review surface.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
---
|
||||
title: Создание команды – Документация Agent Teams
|
||||
description: Определить роли, назначить провайдеры и модели, написать brief команды и настроить worktree isolation и уровни autonomy.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Создание команды
|
||||
|
||||
Команда — это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
---
|
||||
title: Установка – Документация Agent Teams
|
||||
description: Скачать и установить Agent Teams для macOS, Windows или Linux. Готовые сборки, запуск из source, автообновления и требования.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Установка
|
||||
|
||||
Agent Teams распространяется как desktop-приложение для macOS, Windows и Linux.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
---
|
||||
title: Быстрый старт – Документация Agent Teams
|
||||
description: От свежей установки до запущенной команды AI-агентов за несколько минут. Установка, выбор рантайма, создание команды и первый код-ревью.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Быстрый старт
|
||||
|
||||
Этот гайд проводит от свежей установки до первой запущенной команды за несколько минут.
|
||||
|
|
@ -18,6 +24,14 @@
|
|||
Выберите проект под Git — так вы получите лучший опыт. Изоляция через worktree и ревью по diff зависят от Git.
|
||||
:::
|
||||
|
||||
Перед запуском команды проверьте базовое состояние проекта:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Не обязательно иметь идеально чистое дерево, но важно понимать, какие изменения уже были вашими до старта агентов. Так проще доверять task diffs и hunk-level review.
|
||||
|
||||
## 3. Выберите runtime
|
||||
|
||||
Мастер настройки автоматически определит установленные рантаймы на вашей машине. Стандартные варианты:
|
||||
|
|
@ -34,12 +48,32 @@
|
|||
|
||||
Подробная настройка каждого провайдера — в разделе [Настройка рантайма](/ru/guide/runtime-setup).
|
||||
|
||||
Чтобы проверить выбранный runtime вне приложения, запустите соответствующую команду версии:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
codex --version
|
||||
opencode --version
|
||||
```
|
||||
|
||||
Если команда падает в терминале, сначала исправьте runtime installation или `PATH`. Team prompts не смогут компенсировать отсутствующий binary или provider auth.
|
||||
|
||||
## 4. Создайте первую команду
|
||||
|
||||
Начните с маленькой команды: lead, implementation agent и review-oriented agent. Этого достаточно, чтобы проверить workflow без лишнего шума.
|
||||
|
||||
Рекомендованная структура и советы — в разделе [Создание команды](/ru/guide/create-team).
|
||||
|
||||
Для первого запуска используйте примерно такую структуру:
|
||||
|
||||
| Member | Responsibility | Notes |
|
||||
| --- | --- | --- |
|
||||
| Lead | Делит цель на tasks и координирует status | Держите на самом надёжном provider |
|
||||
| Builder | Реализует scoped tasks | Дайте понятные file или feature boundaries |
|
||||
| Reviewer | Проверяет завершённую работу | Попросите фокусироваться на regressions и missing tests |
|
||||
|
||||
Не начинайте сразу с пяти и более teammates. Больше агентов означает больше concurrency, logs, provider usage и риск конфликтов до того, как вы убедились, что setup здоровый.
|
||||
|
||||
## 5. Дайте lead-агенту конкретную цель
|
||||
|
||||
Пишите задачу как инженерному лиду:
|
||||
|
|
@ -48,6 +82,14 @@
|
|||
Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими рефакторингами.
|
||||
```
|
||||
|
||||
Хороший первый prompt содержит scope, safety boundaries и verification:
|
||||
|
||||
```text
|
||||
Improve the docs quickstart. Keep edits inside landing/product-docs. Add practical examples, preserve existing VitePress syntax, and run the docs build before marking tasks done.
|
||||
```
|
||||
|
||||
Избегайте размытых prompts вроде "make the app better" для первого запуска. Lead может дробить большие цели, но хороший input даёт более маленькие tasks и чище review.
|
||||
|
||||
Lead создаёт задачи, назначает работу и координирует teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages в любой момент.
|
||||
|
||||
## 6. Проверьте результат
|
||||
|
|
@ -56,6 +98,12 @@ Lead создаёт задачи, назначает работу и коорд
|
|||
|
||||
Полный процесс ревью — в разделе [Код-ревью](/ru/guide/code-review).
|
||||
|
||||
Перед approval первой task проверьте три вещи:
|
||||
|
||||
1. Task comment объясняет, что изменилось
|
||||
2. Изменённые файлы совпадают со scope задачи
|
||||
3. Verification result виден в task comment или logs
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
---
|
||||
title: Настройка рантайма
|
||||
description: Настройте Claude Code, Codex или OpenCode рантаймы и аутентификацию провайдеров для команд агентов.
|
||||
---
|
||||
|
||||
---
|
||||
title: Настройка рантайма – Документация Agent Teams
|
||||
description: Конфигурация Claude Code, Codex или OpenCode. Авторизация, провайдеры, multimodel mode и предзапусковые проверки.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Настройка рантайма
|
||||
|
||||
Agent Teams — coordination layer. Model work выполняется через локальные runtimes и providers.
|
||||
|
|
@ -9,11 +20,22 @@ Agent Teams — coordination layer. Model work выполняется через
|
|||
- Runtime binary установлен и находится в `PATH`.
|
||||
- Ваш аккаунт провайдера имеет доступ к выбранной модели.
|
||||
- Путь к проекту существует и доступен для чтения.
|
||||
- Приложение и терминал используют одинаковое home/config окружение, когда вы вручную проверяете auth.
|
||||
|
||||
::: tip
|
||||
Начните с одного teammate и одного провайдера. Подтвердите запуск одной команды, прежде чем добавлять multimodel lanes.
|
||||
:::
|
||||
|
||||
Быстрые terminal checks:
|
||||
|
||||
```bash
|
||||
command -v claude
|
||||
command -v codex
|
||||
command -v opencode
|
||||
```
|
||||
|
||||
Запускайте команду для runtime, который планируете использовать. Если вывода нет, установите runtime или исправьте `PATH` до запуска команды.
|
||||
|
||||
## Поддерживаемые пути
|
||||
|
||||
| Путь | CLI по умолчанию | Типичные провайдеры | Когда использовать |
|
||||
|
|
@ -24,6 +46,8 @@ Agent Teams — coordination layer. Model work выполняется через
|
|||
|
||||
Приложение по возможности определяет доступные runtimes и ведёт настройку через UI.
|
||||
|
||||
Gemini встречается во внутренних provider lists, но сейчас скрыт из основного team creation UI, пока launch experience отмечен как in development.
|
||||
|
||||
## Доступ к провайдеру
|
||||
|
||||
У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: подписка, локальная авторизация рантайма или API-ключи в зависимости от выбранного пути.
|
||||
|
|
@ -47,6 +71,8 @@ claude login
|
|||
claude --version
|
||||
```
|
||||
|
||||
Если packaged app пишет "not logged in", хотя терминал работает, сравните `$HOME` и `PATH`, которые видит приложение, с терминалом, где вы делали login. Auth diagnostic log из [Диагностики](/ru/guide/troubleshooting#диагностический-лог-авторизации) - лучшая стартовая точка.
|
||||
|
||||
### Codex
|
||||
|
||||
Установите и авторизуйтесь через CLI OpenAI:
|
||||
|
|
@ -55,6 +81,14 @@ claude --version
|
|||
codex login
|
||||
```
|
||||
|
||||
Затем проверьте, что runtime доступен:
|
||||
|
||||
```bash
|
||||
codex --version
|
||||
```
|
||||
|
||||
Codex-native launches используют Codex account state и model catalog data, когда они доступны. Если model не видна в UI, обновите provider status до редактирования team prompts.
|
||||
|
||||
### OpenCode
|
||||
|
||||
Создайте или отредактируйте `~/.opencode/config.json` (или эквивалентный путь на вашей платформе):
|
||||
|
|
@ -71,6 +105,16 @@ codex login
|
|||
|
||||
Используйте точное имя провайдера, которое ожидает OpenCode. Если вы используете кастомное имя, убедитесь, что оно совпадает с provider ID в строке модели (например, `openrouter/moonshotai/kimi-k2.6` использует блок `openrouter`).
|
||||
|
||||
Примеры model strings:
|
||||
|
||||
| Model string | Provider block, который должен существовать |
|
||||
| --- | --- |
|
||||
| `openrouter/moonshotai/kimi-k2.6` | `openrouter` |
|
||||
| `openai/gpt-5.4` | `openai` |
|
||||
| `anthropic/claude-sonnet-4-6` | `anthropic` |
|
||||
|
||||
Если OpenCode запускается, но teammate не становится deliverable, сначала смотрите lane evidence, а не предполагаете, что model проигнорировала prompt. См. [Диагностика](/ru/guide/troubleshooting#opencode-registered-но-bootstrap-не-подтверждён).
|
||||
|
||||
## Multimodel-режим
|
||||
|
||||
Multimodel-режим может направлять работу через разные provider backends в OpenCode-совместимой конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.
|
||||
|
|
@ -79,6 +123,16 @@ Multimodel-режим может направлять работу через р
|
|||
Каждый teammate может использовать свою пару `providerId` + `model`. В UI редактирования команды разверните опции member, чтобы переопределить глобальные значения.
|
||||
:::
|
||||
|
||||
Консервативный multimodel setup:
|
||||
|
||||
| Role | Provider | Why |
|
||||
| --- | --- | --- |
|
||||
| Lead | Claude или Codex | Держит coordination на самом надёжном provider |
|
||||
| Builder | OpenCode | Даёт broad model routing для implementation work |
|
||||
| Reviewer | Claude, Codex или второй OpenCode model | Отделяет review judgment от builder lane |
|
||||
|
||||
Не смешивайте много незнакомых providers в первом launch. Подтвердите одну маленькую task на каждую lane до broad work.
|
||||
|
||||
## Чеклист перед запуском
|
||||
|
||||
Перед запуском команды:
|
||||
|
|
@ -96,3 +150,13 @@ Multimodel-режим может направлять работу через р
|
|||
::: warning Считайте ошибки setup setup-проблемами
|
||||
Если auth падает, имя модели отклонено или binary runtime не найден — сначала исправьте настройку. Не меняйте team prompts или код проекта, чтобы обойти проблему конфигурации рантайма.
|
||||
:::
|
||||
|
||||
Используйте эту таблицу решений:
|
||||
|
||||
| Symptom | Better first action |
|
||||
| --- | --- |
|
||||
| Binary not found | Исправить installation или `PATH` |
|
||||
| Login работает в terminal, но не app | Проверить Electron auth diagnostic log и environment |
|
||||
| Model rejected | Проверить точный model id в provider runtime |
|
||||
| Repeated 429s | Уменьшить concurrency или сменить model/provider |
|
||||
| OpenCode lane stuck | Проверить lane manifest и `opencode-sessions.json` |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
---
|
||||
title: Диагностика
|
||||
description: Исправление ошибок запуска, пропавших ответов агентов, rate limits, проблем auth и lane bootstrap в Agent Teams.
|
||||
---
|
||||
|
||||
---
|
||||
title: Диагностика – Документация Agent Teams
|
||||
description: Решение проблем с запуском команд, отсутствующими ответами агентов, rate limits, CLI auth и lane bootstrap stalls через локальные диагностики.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Диагностика
|
||||
|
||||
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing и provider limits.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
---
|
||||
title: Документация Agent Teams – Запускайте команды AI-агентов из локального desktop-приложения
|
||||
description: Документация Agent Teams, бесплатного desktop-приложения для оркестрации AI-агентов. Создавайте команды, наблюдайте за канбан-доской, ревьюйте изменения и координируйте Claude, Codex, OpenCode и multimodel workflows.
|
||||
layout: home
|
||||
hero:
|
||||
name: Документация Agent Teams
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
---
|
||||
title: Концепции
|
||||
description: Основной словарь Agent Teams — команды, lead-агенты, teammates, задачи, канбан, inboxes, рантаймы и review.
|
||||
---
|
||||
|
||||
---
|
||||
title: Концепции – Документация Agent Teams
|
||||
description: Основные термины Agent Teams: teams, leads, teammates, tasks, kanban, inboxes, agent blocks, context phases, runtimes, providers.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Концепции
|
||||
|
||||
Основные термины Agent Teams. Эта страница задаёт общий словарь для приложения, доски задач, сообщений и review flow.
|
||||
|
|
@ -6,16 +17,20 @@
|
|||
|
||||
Team - именованная группа агентов, привязанная к одному project path. У команды есть lead, опциональные teammates, настройки runtime/provider, prompts, inboxes, tasks и локальное состояние запуска.
|
||||
|
||||
## Lead
|
||||
## Lead {#lead}
|
||||
|
||||
Lead - координатор команды. Он превращает цель пользователя в tasks, назначает или перенаправляет teammates, отслеживает blockers, запрашивает review и двигает работу по board.
|
||||
|
||||
[Teammate →](#teammate)
|
||||
|
||||
Сообщения lead доставляются иначе, чем сообщения teammate: приложение ретранслирует записи inbox в lead runtime, а teammates читают свои inbox-файлы между turns.
|
||||
|
||||
## Teammate
|
||||
## Teammate {#teammate}
|
||||
|
||||
Teammate - не-lead агент в команде. Обычно teammate отвечает за сфокусированную роль: builder, reviewer, researcher или tester. Teammate может получать direct messages, task assignments, task comments и review requests.
|
||||
|
||||
[Lead ↑](#lead)
|
||||
|
||||
## Task
|
||||
|
||||
Task - долговечная единица работы. У неё есть id, status, owner, description, comments, logs, attachments, task references и reviewable changes.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
---
|
||||
title: FAQ
|
||||
description: Часто задаваемые вопросы об Agent Teams — цена, доступ к моделям, рантаймы, приватность, ревью и диагностика.
|
||||
---
|
||||
|
||||
---
|
||||
title: FAQ – Документация Agent Teams
|
||||
description: Часто задаваемые вопросы о цене, доступе к моделям, настройке рантаймов, приватности данных, worktree isolation и код-ревью.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# FAQ
|
||||
|
||||
## Agent Teams бесплатный?
|
||||
|
|
@ -18,6 +29,18 @@
|
|||
|
||||
OpenCode setup отделён от Claude Code и Codex setup. Если launch fails, сначала проверьте runtime status и provider auth, а не меняйте team prompt.
|
||||
|
||||
## Как проверить, что runtime готов?
|
||||
|
||||
Сначала запустите runtime command в терминале:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
codex --version
|
||||
opencode --version
|
||||
```
|
||||
|
||||
Затем проверьте provider auth для выбранного пути. Если command или auth check не работает вне Agent Teams, исправьте setup до запуска команды.
|
||||
|
||||
## Приложение загружает мой код на серверы Agent Teams?
|
||||
|
||||
Нет. Agent Teams не является cloud code-sync сервисом. Но provider-backed model calls могут получать prompt context в зависимости от выбранного runtime.
|
||||
|
|
@ -34,6 +57,14 @@ Prompt context, selected file contents, tool results, command output, task text,
|
|||
|
||||
Да. Агенты могут писать teammates, комментировать tasks, координироваться между teams и использовать task references, чтобы разговор оставался привязанным к работе.
|
||||
|
||||
## Что написать в первый team prompt?
|
||||
|
||||
Дайте lead конкретный outcome, file или feature boundaries, risk limits и verification expectations. Например:
|
||||
|
||||
```text
|
||||
Improve the docs quickstart. Keep edits inside landing/product-docs, add practical examples, and run the docs build before marking work done.
|
||||
```
|
||||
|
||||
## Можно ревьюить код перед принятием?
|
||||
|
||||
Да. Review flow построен вокруг task-scoped diffs и hunk-level decisions.
|
||||
|
|
@ -46,6 +77,10 @@ Agent Block - скрытый agent-only text в маркерах вроде `<in
|
|||
|
||||
Solo mode - команда из одного агента. Подходит для небольших задач и меньшего coordination overhead.
|
||||
|
||||
## Стоит ли включать worktree isolation?
|
||||
|
||||
Включайте, когда несколько OpenCode teammates могут параллельно редактировать один Git project. Это снижает file conflicts, но требует Git-tracked project и сейчас применяется к OpenCode members.
|
||||
|
||||
## Могут ли разные teammates использовать разных providers?
|
||||
|
||||
Да, provider/model settings могут задаваться per team member, если выбранный runtime path это поддерживает. OpenCode - основной путь для широкой multi-provider routing.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
---
|
||||
title: Приватность и локальные данные
|
||||
description: Что desktop-приложение Agent Teams хранит локально и какие данные могут покинуть машину через provider-backed models.
|
||||
---
|
||||
|
||||
---
|
||||
title: Приватность и локальные данные – Документация Agent Teams
|
||||
description: Что Agent Teams хранит локально, что может покинуть машину через provider-backed model calls, и практические рекомендации по приватности.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Приватность и локальные данные
|
||||
|
||||
Agent Teams local-first, но выбранный runtime/provider path всё равно важен. Эта страница описывает, что desktop app хранит локально и что может покинуть машину, когда agents вызывают provider-backed models.
|
||||
|
|
@ -33,6 +44,15 @@ Agent Teams сам по себе не является cloud code-sync серв
|
|||
|
||||
Provider authentication, provider-side retention, training, logging, regional processing и billing регулируются выбранным provider/runtime. Для sensitive projects проверяйте их policies.
|
||||
|
||||
Типичные примеры:
|
||||
|
||||
| Action | Data, которое может уйти через runtime/provider |
|
||||
| --- | --- |
|
||||
| Попросить агента редактировать file | Task prompt, relevant file contents, tool results и command output |
|
||||
| Прикрепить screenshot | Attachment content и связанный task/comment text |
|
||||
| Попросить code review | Diff context, selected files, comments и verification logs |
|
||||
| Debug failing command | Error output, stack traces и referenced source snippets |
|
||||
|
||||
## Чего app не гарантирует
|
||||
|
||||
- App не может гарантировать, что provider-backed model calls никогда не получат private code.
|
||||
|
|
@ -51,6 +71,14 @@ Provider authentication, provider-side retention, training, logging, regional pr
|
|||
- Проверяйте generated prompts, task descriptions и attached files перед работой с confidential material.
|
||||
- Выбирайте provider/model paths, которые соответствуют вашим privacy requirements.
|
||||
|
||||
Перед использованием Agent Teams на sensitive repository:
|
||||
|
||||
1. Уберите secrets из working tree и task attachments
|
||||
2. Выберите runtime/provider path, который вам разрешено использовать
|
||||
3. Начните с low autonomy и small tasks
|
||||
4. Review task prompts и generated comments до расширения scope
|
||||
5. Храните logs локально, если не собираетесь специально делиться ими для support
|
||||
|
||||
## Open source
|
||||
|
||||
Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking, inboxes, runtime diagnostics и review flows.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
---
|
||||
title: Провайдеры и рантаймы
|
||||
description: Поддерживаемые runtime paths, provider ids, model ids, multi-provider стратегия и capability checks в Agent Teams.
|
||||
---
|
||||
|
||||
---
|
||||
title: Провайдеры и рантаймы – Документация Agent Teams
|
||||
description: Поддерживаемые runtime paths (Claude Code, Codex, OpenCode), provider IDs, модели, multi-provider стратегии и capability checks.
|
||||
lang: ru-RU
|
||||
---
|
||||
|
||||
# Провайдеры и рантаймы
|
||||
|
||||
Agent Teams отделяет orchestration от model access. Приложение управляет teams, tasks, messages, launch state и review UI; выбранный runtime/provider path выполняет реальную model work.
|
||||
|
|
@ -33,6 +44,8 @@ Runtime отвечает за:
|
|||
| Codex | Codex / OpenAI-backed models | Для Codex-native workflows | Использует Codex runtime integration и Codex auth/account state, когда они доступны. Часть diagnostics отличается от Claude transcripts. |
|
||||
| OpenCode | OpenCode-managed model routing | Для multi-provider teams и широкой model coverage | OpenCode может маршрутизировать через множество model providers. Agent Teams считает OpenCode lanes runtime-specific evidence и не угадывает attribution при ambiguous lane identity. |
|
||||
|
||||
Gemini provider ids существуют во внутренних configuration paths, но Gemini сейчас скрыт из основного team creation UI, пока launch flow остаётся in development.
|
||||
|
||||
## Provider ids
|
||||
|
||||
В team/runtime configuration приложение сейчас распознаёт такие provider ids:
|
||||
|
|
@ -46,6 +59,20 @@ Runtime отвечает за:
|
|||
|
||||
Эта таблица не гарантирует, что каждый provider authenticated, installed или доступен для каждой модели на каждой машине. Runtime status и capability checks - source of truth для конкретного launch.
|
||||
|
||||
## Model ids
|
||||
|
||||
Model ids передаются в выбранный runtime. Agent Teams не переписывает provider model catalog в универсальную naming scheme.
|
||||
|
||||
Примеры:
|
||||
|
||||
| Provider path | Example model id | Notes |
|
||||
| --- | --- | --- |
|
||||
| Claude Code | `opus`, `sonnet` или full Claude model id | Availability зависит от Claude Code и account access |
|
||||
| Codex | `gpt-5.4`, `gpt-5.3-codex` | Availability приходит из Codex account/runtime state |
|
||||
| OpenCode | `openrouter/moonshotai/kimi-k2.6` | Prefix должен совпадать с OpenCode provider configuration |
|
||||
|
||||
Если model name rejected, сначала проверьте его прямо в runtime/provider. Изменение team brief не заставит unavailable model запуститься.
|
||||
|
||||
## Multi-provider strategy
|
||||
|
||||
Agent Teams остаётся provider-aware, но не provider-owned:
|
||||
|
|
@ -55,6 +82,14 @@ Agent Teams остаётся provider-aware, но не provider-owned:
|
|||
- model availability, auth, rate limits и tool behavior остаются ответственностью runtime/provider
|
||||
- OpenCode - основной путь, когда одной team нужны разные provider/model lanes
|
||||
|
||||
Рекомендуемые patterns:
|
||||
|
||||
| Pattern | When it helps | Risk |
|
||||
| --- | --- | --- |
|
||||
| One provider for all members | First launch, sensitive repos, simplest debugging | Shared rate limits могут остановить всю team |
|
||||
| Strong lead + cheaper builders | Planning/review остаются надёжными, implementation дешевле | Builder output может требовать более строгого review |
|
||||
| Separate builder and reviewer models | Ловит model-specific blind spots | Больше setup и attribution для проверки |
|
||||
|
||||
## Стоимость providers
|
||||
|
||||
Agent Teams бесплатен и open source. Provider usage зависит от выбранного runtime/provider: subscription limits, API keys, account auth, rate limits и provider policies остаются внешними для приложения.
|
||||
|
|
@ -65,6 +100,15 @@ Agent Teams бесплатен и open source. Provider usage зависит о
|
|||
|
||||
Capability checks могут показать, что provider существует, но не authenticated; model list недоступен; runtime path отсутствует; или конкретная extension capability unsupported. Считайте это setup diagnostics, а не task failures.
|
||||
|
||||
Типичные setup fixes:
|
||||
|
||||
| Check result | What to do |
|
||||
| --- | --- |
|
||||
| Runtime missing | Установить CLI или исправить `PATH` |
|
||||
| Provider unauthenticated | Запустить provider login flow или добавить нужный API key |
|
||||
| Model unavailable | Выбрать model, которая видна в model list этого runtime |
|
||||
| Capability unsupported | Использовать другой runtime path для этого teammate |
|
||||
|
||||
## Ожидаемые ограничения
|
||||
|
||||
- Runtime support не означает одинаковый feature parity для Claude Code, Codex и OpenCode.
|
||||
|
|
|
|||
52
landing/server/routes/llms.txt.ts
Normal file
52
landing/server/routes/llms.txt.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { defaultLocale, getLocalizedPagePath, sitemapPages, supportedLocales } from "~/data/i18n";
|
||||
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = trimTrailingSlash(
|
||||
(config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai"
|
||||
);
|
||||
const githubRepo = (config.public.githubRepo as string) || "777genius/agent-teams-ai";
|
||||
const githubUrl = `https://github.com/${githubRepo}`;
|
||||
const releasesUrl = `${githubUrl}/releases`;
|
||||
const toSiteUrl = (path: string) => `${siteUrl}${path === "/" ? "/" : `/${path.replace(/^\/+/, "")}`}`;
|
||||
|
||||
setHeader(event, "content-type", "text/plain; charset=utf-8");
|
||||
|
||||
const localizedPages = sitemapPages
|
||||
.flatMap((page) =>
|
||||
supportedLocales.map((locale) => {
|
||||
const path = getLocalizedPagePath(page, locale.code);
|
||||
const label = page === "/" ? "Landing" : "Download";
|
||||
return `- ${label} (${locale.iso}): ${toSiteUrl(path)}`;
|
||||
})
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `# Agent Teams
|
||||
|
||||
> Agent Teams is a free, open-source local desktop app for orchestrating AI agent teams across Claude, Codex, and OpenCode. It provides a live kanban board, agent-to-agent messaging, task logs, code review, downloads for macOS, Windows, and Linux, and local-first control.
|
||||
|
||||
## Primary URLs
|
||||
|
||||
- Homepage (${defaultLocale}): ${toSiteUrl("/")}
|
||||
- Download: ${toSiteUrl("/download")}
|
||||
- Documentation: ${toSiteUrl("/docs/")}
|
||||
- Documentation llms.txt: ${toSiteUrl("/docs/llms.txt")}
|
||||
- GitHub repository: ${githubUrl}
|
||||
- Releases: ${releasesUrl}
|
||||
- Sitemap: ${toSiteUrl("/sitemap.xml")}
|
||||
|
||||
## Localized landing pages
|
||||
|
||||
${localizedPages}
|
||||
|
||||
## Useful context
|
||||
|
||||
- The app itself is free and open source.
|
||||
- Provider/runtime access is supplied by the user through supported local runtimes or provider accounts.
|
||||
- The product is local-first: coordination state and project workflows are designed to run on the user's machine.
|
||||
- Key workflows: create an agent team, assign or let agents create tasks, watch progress on a kanban board, inspect task logs, and review code changes.
|
||||
`;
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
|
||||
const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, "");
|
||||
|
||||
setHeader(event, "content-type", "text/plain; charset=utf-8");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { generateSitemapRoutes } from "~/data/i18n";
|
||||
import { defaultLocale, getLocalizedPagePath, sitemapPages, supportedLocales } from "~/data/i18n";
|
||||
import { screenshots } from "~/data/screenshots";
|
||||
|
||||
const escapeXml = (value: string) =>
|
||||
value
|
||||
|
|
@ -12,17 +13,38 @@ const buildDate = new Date().toISOString().split("T")[0];
|
|||
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const siteUrl = (config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai";
|
||||
const siteUrl = ((config.public.siteUrl as string) || "https://777genius.github.io/agent-teams-ai").replace(/\/+$/, "");
|
||||
const toSiteUrl = (path: string) => `${siteUrl}${path === "/" ? "/" : `/${path.replace(/^\/+/, "")}`}`;
|
||||
const homeImagePaths = ["og-image.png", ...screenshots.map((screenshot) => screenshot.path)];
|
||||
const downloadImagePaths = ["og-image.png", "logo-192.png"];
|
||||
|
||||
setHeader(event, "content-type", "application/xml; charset=utf-8");
|
||||
|
||||
const routes = generateSitemapRoutes();
|
||||
const entries = sitemapPages.flatMap((page) =>
|
||||
supportedLocales.map((locale) => ({
|
||||
path: getLocalizedPagePath(page, locale.code),
|
||||
page
|
||||
}))
|
||||
);
|
||||
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${routes
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
||||
${entries
|
||||
.map(
|
||||
(path) =>
|
||||
` <url>\n <loc>${escapeXml(`${siteUrl}${path}`)}</loc>\n <lastmod>${buildDate}</lastmod>\n </url>`
|
||||
({ path, page }) => {
|
||||
const alternates = supportedLocales
|
||||
.map((locale) => {
|
||||
const href = toSiteUrl(getLocalizedPagePath(page, locale.code));
|
||||
return ` <xhtml:link rel="alternate" hreflang="${escapeXml(locale.iso)}" href="${escapeXml(href)}" />`;
|
||||
})
|
||||
.join("\n");
|
||||
const imagePaths = page === "/" ? homeImagePaths : downloadImagePaths;
|
||||
const images = imagePaths
|
||||
.map((imagePath) => ` <image:image>\n <image:loc>${escapeXml(toSiteUrl(imagePath))}</image:loc>\n </image:image>`)
|
||||
.join("\n");
|
||||
const defaultHref = toSiteUrl(getLocalizedPagePath(page, defaultLocale));
|
||||
return ` <url>\n <loc>${escapeXml(toSiteUrl(path))}</loc>\n${alternates}\n <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(defaultHref)}" />\n${images}\n <lastmod>${buildDate}</lastmod>\n </url>`;
|
||||
}
|
||||
)
|
||||
.join("\n")}
|
||||
</urlset>
|
||||
|
|
|
|||
|
|
@ -40,11 +40,15 @@ export function drawAgents(
|
|||
const color = node.color ?? getStateColor(node.state);
|
||||
const isSelected = node.id === selectedId;
|
||||
const isHovered = node.id === hoveredId;
|
||||
const hasErrorException = node.exceptionTone === 'error';
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
|
||||
if (simplify) {
|
||||
if (hasErrorException) {
|
||||
drawExceptionGlow(ctx, x, y, r, time, true);
|
||||
}
|
||||
drawHexagon(ctx, x, y, r);
|
||||
ctx.fillStyle = isSelected ? 'rgba(100, 200, 255, 0.15)' : COLORS.nodeInterior;
|
||||
ctx.fill();
|
||||
|
|
@ -63,6 +67,9 @@ export function drawAgents(
|
|||
|
||||
// Outer glow
|
||||
drawGlow(ctx, x, y, r, color);
|
||||
if (hasErrorException) {
|
||||
drawExceptionGlow(ctx, x, y, r, time);
|
||||
}
|
||||
|
||||
// Hexagonal body with interior fill
|
||||
drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered);
|
||||
|
|
@ -250,6 +257,47 @@ function drawExceptionPip(
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawExceptionGlow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
r: number,
|
||||
time: number,
|
||||
simplified = false
|
||||
): void {
|
||||
const pulse = 0.5 + 0.5 * Math.sin(time * 4.2);
|
||||
const glowAlpha = simplified ? 0.12 : 0.16 + pulse * 0.08;
|
||||
const strokeAlpha = simplified ? 0.7 : 0.5 + pulse * 0.24;
|
||||
const outerR = r + (simplified ? 13 : 20);
|
||||
const ringR = r + (simplified ? 4 : 7);
|
||||
const arcR = r + (simplified ? 9 : 13);
|
||||
const errorColor = '#ef4444';
|
||||
|
||||
ctx.save();
|
||||
const grad = ctx.createRadialGradient(x, y, r * 0.6, x, y, outerR);
|
||||
grad.addColorStop(0, hexWithAlpha(errorColor, glowAlpha));
|
||||
grad.addColorStop(0.68, hexWithAlpha(errorColor, glowAlpha * 0.55));
|
||||
grad.addColorStop(1, hexWithAlpha(errorColor, 0));
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, outerR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = hexWithAlpha(errorColor, strokeAlpha);
|
||||
ctx.lineWidth = simplified ? 2 : 2.4;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, arcR, time * 1.35, time * 1.35 + Math.PI * 1.3);
|
||||
ctx.strokeStyle = hexWithAlpha('#f87171', simplified ? 0.78 : 0.62 + pulse * 0.22);
|
||||
ctx.lineWidth = simplified ? 1.4 : 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawLaunchStage(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
|
|
|
|||
|
|
@ -150,8 +150,6 @@ const STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS = 96;
|
|||
const STRICT_SMALL_TEAM_RADIUS_EPSILON = 0.5;
|
||||
const STRICT_SMALL_TEAM_RADIUS_STEP = 24;
|
||||
const GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT = 2;
|
||||
const GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT = 3;
|
||||
const GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT = 6;
|
||||
const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
|
||||
const GRID_UNDER_LEAD_ROW_GAP = 77.7;
|
||||
const ROW_ORBIT_MIN_OWNER_COUNT = 6;
|
||||
|
|
@ -1602,9 +1600,7 @@ function planGridUnderLeadOwnerSlots(
|
|||
}
|
||||
|
||||
function getGridUnderLeadColumnCount(ownerCount: number): number {
|
||||
return ownerCount === GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT
|
||||
? GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT
|
||||
: GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT;
|
||||
return Math.min(ownerCount, GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT);
|
||||
}
|
||||
|
||||
function shouldUseStrictSmallTeamCardinalLayout(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
|
||||
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
|
||||
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
|
||||
import {
|
||||
classifyIdleNotificationText,
|
||||
getIdleGraphLabel,
|
||||
|
|
@ -131,7 +132,7 @@ export class TeamGraphAdapter {
|
|||
provisioningProgress?: TeamProvisioningProgress | null,
|
||||
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
||||
layoutMode: GraphLayoutMode = 'radial',
|
||||
layoutMode: GraphLayoutMode = DEFAULT_TEAM_GRAPH_LAYOUT_MODE,
|
||||
gridOwnerOrder?: readonly string[],
|
||||
activeTaskLogActivity?: Record<string, true>
|
||||
): GraphDataPort {
|
||||
|
|
@ -290,7 +291,7 @@ export class TeamGraphAdapter {
|
|||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
||||
mode: GraphLayoutMode = 'radial',
|
||||
mode: GraphLayoutMode = DEFAULT_TEAM_GRAPH_LAYOUT_MODE,
|
||||
gridOwnerOrder?: readonly string[]
|
||||
): GraphLayoutPort {
|
||||
const ownerOrder: string[] = [];
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ const PREVIEW_CACHE_TTL_MS = 3_500;
|
|||
const DEFAULT_MAX_ITEMS = 3;
|
||||
const DEFAULT_TEXT_LIMIT = 200;
|
||||
|
||||
interface PendingReloadOptions {
|
||||
forceRefresh: boolean;
|
||||
background: boolean;
|
||||
}
|
||||
|
||||
function normalizeMemberName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
|
@ -65,15 +70,79 @@ function mergeMemberPreviews(
|
|||
return next;
|
||||
}
|
||||
|
||||
function hasUnloadedMemberPreview(
|
||||
memberNames: readonly string[],
|
||||
previewsByMember: ReadonlyMap<string, MemberLogPreviewMember>
|
||||
): boolean {
|
||||
return memberNames.some((memberName) => !previewsByMember.has(normalizeMemberName(memberName)));
|
||||
}
|
||||
|
||||
function hasEmptyOrUnloadedMemberPreview(
|
||||
memberNames: readonly string[],
|
||||
previewsByMember: ReadonlyMap<string, MemberLogPreviewMember>
|
||||
): boolean {
|
||||
return memberNames.some((memberName) => {
|
||||
const preview = previewsByMember.get(normalizeMemberName(memberName));
|
||||
return !preview || preview.items.length === 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hasInFlightMemberPreviewRequest(
|
||||
memberNames: readonly string[],
|
||||
activeRequestKeyByMember: ReadonlyMap<string, string>,
|
||||
inFlightRequests: ReadonlyMap<string, unknown>
|
||||
): boolean {
|
||||
return memberNames.some((memberName) => {
|
||||
const activeRequestKey = activeRequestKeyByMember.get(normalizeMemberName(memberName));
|
||||
return activeRequestKey ? inFlightRequests.has(activeRequestKey) : false;
|
||||
});
|
||||
}
|
||||
|
||||
function hasPendingLoadingReload(
|
||||
pendingReload: PendingReloadOptions | null,
|
||||
memberNames: readonly string[],
|
||||
previewsByMember: ReadonlyMap<string, MemberLogPreviewMember>
|
||||
): boolean {
|
||||
return (
|
||||
pendingReload?.forceRefresh === true &&
|
||||
hasEmptyOrUnloadedMemberPreview(memberNames, previewsByMember)
|
||||
);
|
||||
}
|
||||
|
||||
function hasActiveMemberPreviewRequest(
|
||||
memberNames: readonly string[],
|
||||
requestKey: string,
|
||||
activeRequestKeyByMember: ReadonlyMap<string, string>
|
||||
): boolean {
|
||||
return memberNames.some(
|
||||
(memberName) => activeRequestKeyByMember.get(normalizeMemberName(memberName)) === requestKey
|
||||
);
|
||||
}
|
||||
|
||||
function hasVisibleActiveMemberPreviewRequest(
|
||||
requestedMemberNames: readonly string[],
|
||||
visibleMemberNames: readonly string[],
|
||||
requestKey: string,
|
||||
activeRequestKeyByMember: ReadonlyMap<string, string>
|
||||
): boolean {
|
||||
const visibleMemberNameSet = new Set(visibleMemberNames.map(normalizeMemberName));
|
||||
return requestedMemberNames.some((memberName) => {
|
||||
const normalizedMemberName = normalizeMemberName(memberName);
|
||||
return (
|
||||
visibleMemberNameSet.has(normalizedMemberName) &&
|
||||
activeRequestKeyByMember.get(normalizedMemberName) === requestKey
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function laneIdForMember(
|
||||
memberName: string,
|
||||
laneIdsByMember: Readonly<Record<string, string>>
|
||||
): string {
|
||||
return (
|
||||
laneIdsByMember[memberName]?.trim() ??
|
||||
laneIdsByMember[normalizeMemberName(memberName)]?.trim() ??
|
||||
''
|
||||
);
|
||||
const directLaneId = laneIdsByMember[memberName]?.trim();
|
||||
if (directLaneId) return directLaneId;
|
||||
const normalizedLaneId = laneIdsByMember[normalizeMemberName(memberName)]?.trim();
|
||||
return normalizedLaneId || '';
|
||||
}
|
||||
|
||||
function buildMemberCacheKey(input: {
|
||||
|
|
@ -92,6 +161,24 @@ function buildMemberCacheKey(input: {
|
|||
]);
|
||||
}
|
||||
|
||||
function buildLaneIdsKey(laneIdsByMember: Readonly<Record<string, string>>): string {
|
||||
const laneEntriesByMember = new Map<string, string>();
|
||||
for (const [memberName, laneId] of Object.entries(laneIdsByMember)) {
|
||||
const normalizedMemberName = normalizeMemberName(memberName);
|
||||
const trimmedLaneId = laneId.trim();
|
||||
if (!normalizedMemberName || !trimmedLaneId || laneEntriesByMember.has(normalizedMemberName)) {
|
||||
continue;
|
||||
}
|
||||
laneEntriesByMember.set(normalizedMemberName, trimmedLaneId);
|
||||
}
|
||||
return JSON.stringify(
|
||||
Array.from(laneEntriesByMember.entries()).sort((left, right) => {
|
||||
const byMember = left[0].localeCompare(right[0]);
|
||||
return byMember !== 0 ? byMember : left[1].localeCompare(right[1]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function buildLaneIdsForMembers(
|
||||
memberNames: readonly string[],
|
||||
laneIdsByMember: Readonly<Record<string, string>>
|
||||
|
|
@ -168,6 +255,10 @@ export function useGraphMemberLogPreviews(input: {
|
|||
}
|
||||
return result;
|
||||
}, [input.memberNames]);
|
||||
const laneKey = useMemo(
|
||||
() => buildLaneIdsKey(buildLaneIdsForMembers(memberNames, laneIdsByMember)),
|
||||
[laneIdsByMember, memberNames]
|
||||
);
|
||||
const memberKey = useMemo(
|
||||
() =>
|
||||
memberNames
|
||||
|
|
@ -186,25 +277,42 @@ export function useGraphMemberLogPreviews(input: {
|
|||
const inFlightRef = useRef(new Map<string, Promise<Map<string, MemberLogPreviewMember>>>());
|
||||
const activeRequestKeyByMemberRef = useRef(new Map<string, string>());
|
||||
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingReloadRef = useRef<PendingReloadOptions | null>(null);
|
||||
const requestGenerationRef = useRef(0);
|
||||
const teamNameRef = useRef(input.teamName);
|
||||
const laneKeyRef = useRef(laneKey);
|
||||
const memberNamesRef = useRef(memberNames);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
previewsByMemberRef.current = previewsByMember;
|
||||
}, [previewsByMember]);
|
||||
previewsByMemberRef.current = previewsByMember;
|
||||
memberNamesRef.current = memberNames;
|
||||
|
||||
const clearScheduledReload = useCallback((): void => {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
pendingReloadRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (teamNameRef.current !== input.teamName) {
|
||||
teamNameRef.current = input.teamName;
|
||||
laneKeyRef.current = laneKey;
|
||||
requestGenerationRef.current += 1;
|
||||
clearScheduledReload();
|
||||
cacheRef.current.clear();
|
||||
inFlightRef.current.clear();
|
||||
activeRequestKeyByMemberRef.current.clear();
|
||||
setPreviewsByMember(new Map());
|
||||
const emptyPreviews = new Map<string, MemberLogPreviewMember>();
|
||||
previewsByMemberRef.current = emptyPreviews;
|
||||
setPreviewsByMember(emptyPreviews);
|
||||
}
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
setLoading(false);
|
||||
}
|
||||
setError(null);
|
||||
}, [enabled, input.teamName, memberKey, memberNames.length]);
|
||||
}, [clearScheduledReload, enabled, input.teamName, laneKey, memberKey, memberNames.length]);
|
||||
|
||||
const loadPreviews = useCallback(
|
||||
async (options?: { forceRefresh?: boolean; background?: boolean }): Promise<void> => {
|
||||
|
|
@ -221,6 +329,7 @@ export function useGraphMemberLogPreviews(input: {
|
|||
const membersToRequest: string[] = [];
|
||||
const cachedMembers: MemberLogPreviewMember[] = [];
|
||||
let hasMissingPreview = false;
|
||||
let hasEmptyOrMissingPreviewForForceRefresh = false;
|
||||
|
||||
for (const memberName of memberNames) {
|
||||
const cacheKey = buildMemberCacheKey({
|
||||
|
|
@ -238,9 +347,13 @@ export function useGraphMemberLogPreviews(input: {
|
|||
membersToRequest.push(memberName);
|
||||
}
|
||||
const normalizedMemberName = normalizeMemberName(memberName);
|
||||
if (!cached && !previewsByMemberRef.current.has(normalizedMemberName)) {
|
||||
const existingPreview = previewsByMemberRef.current.get(normalizedMemberName);
|
||||
if (!cached && !existingPreview) {
|
||||
hasMissingPreview = true;
|
||||
}
|
||||
if (options?.forceRefresh && (!existingPreview || existingPreview.items.length === 0)) {
|
||||
hasEmptyOrMissingPreviewForForceRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedMembers.length > 0) {
|
||||
|
|
@ -263,11 +376,31 @@ export function useGraphMemberLogPreviews(input: {
|
|||
forceRefresh: options?.forceRefresh,
|
||||
});
|
||||
const requestTeamName = input.teamName;
|
||||
const requestGeneration = requestGenerationRef.current;
|
||||
for (const memberName of membersToRequest) {
|
||||
activeRequestKeyByMemberRef.current.set(normalizeMemberName(memberName), requestKey);
|
||||
}
|
||||
const requestStillActive = (): boolean =>
|
||||
mountedRef.current &&
|
||||
teamNameRef.current === requestTeamName &&
|
||||
requestGenerationRef.current === requestGeneration &&
|
||||
hasActiveMemberPreviewRequest(
|
||||
membersToRequest,
|
||||
requestKey,
|
||||
activeRequestKeyByMemberRef.current
|
||||
);
|
||||
const requestStillVisible = (): boolean =>
|
||||
mountedRef.current &&
|
||||
teamNameRef.current === requestTeamName &&
|
||||
requestGenerationRef.current === requestGeneration &&
|
||||
hasVisibleActiveMemberPreviewRequest(
|
||||
membersToRequest,
|
||||
memberNamesRef.current,
|
||||
requestKey,
|
||||
activeRequestKeyByMemberRef.current
|
||||
);
|
||||
|
||||
if (!options?.background && hasMissingPreview) {
|
||||
if ((!options?.background && hasMissingPreview) || hasEmptyOrMissingPreviewForForceRefresh) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
|
@ -288,31 +421,39 @@ export function useGraphMemberLogPreviews(input: {
|
|||
.then((response) => {
|
||||
const normalized = normalizeMemberLogPreviewResponse(response);
|
||||
const members = memberMapFromResponse(normalized.members);
|
||||
for (const member of members.values()) {
|
||||
cacheRef.current.set(
|
||||
buildMemberCacheKey({
|
||||
teamName: input.teamName,
|
||||
memberName: member.memberName,
|
||||
laneIdsByMember,
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
}),
|
||||
{
|
||||
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
|
||||
member,
|
||||
}
|
||||
);
|
||||
if (
|
||||
mountedRef.current &&
|
||||
teamNameRef.current === requestTeamName &&
|
||||
requestGenerationRef.current === requestGeneration
|
||||
) {
|
||||
for (const member of members.values()) {
|
||||
cacheRef.current.set(
|
||||
buildMemberCacheKey({
|
||||
teamName: input.teamName,
|
||||
memberName: member.memberName,
|
||||
laneIdsByMember,
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
}),
|
||||
{
|
||||
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
|
||||
member,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
return members;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightRef.current.delete(requestKey);
|
||||
if (inFlightRef.current.get(requestKey) === request) {
|
||||
inFlightRef.current.delete(requestKey);
|
||||
}
|
||||
});
|
||||
inFlightRef.current.set(requestKey, request);
|
||||
}
|
||||
|
||||
const members = await request;
|
||||
if (teamNameRef.current !== requestTeamName) {
|
||||
if (!requestStillActive()) {
|
||||
return;
|
||||
}
|
||||
const currentMembers = Array.from(members.values()).filter((member) => {
|
||||
|
|
@ -324,70 +465,129 @@ export function useGraphMemberLogPreviews(input: {
|
|||
if (currentMembers.length > 0) {
|
||||
setPreviewsByMember((current) => mergeMemberPreviews(current, currentMembers));
|
||||
}
|
||||
setError(null);
|
||||
if (requestStillVisible()) {
|
||||
setError(null);
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (teamNameRef.current !== requestTeamName) {
|
||||
if (!requestStillVisible()) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load graph log previews'
|
||||
);
|
||||
} finally {
|
||||
if (teamNameRef.current === requestTeamName) {
|
||||
if (
|
||||
requestStillVisible() &&
|
||||
!hasInFlightMemberPreviewRequest(
|
||||
memberNamesRef.current,
|
||||
activeRequestKeyByMemberRef.current,
|
||||
inFlightRef.current
|
||||
) &&
|
||||
!hasPendingLoadingReload(
|
||||
pendingReloadRef.current,
|
||||
memberNamesRef.current,
|
||||
previewsByMemberRef.current
|
||||
)
|
||||
) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, input.teamName, laneIdsByMember, maxItemsPerMember, memberNames, textLimit]
|
||||
);
|
||||
const loadPreviewsRef = useRef(loadPreviews);
|
||||
loadPreviewsRef.current = loadPreviews;
|
||||
|
||||
const scheduleReload = useCallback(
|
||||
(options?: { forceRefresh?: boolean; background?: boolean }) => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
if (memberNamesRef.current.length === 0) return;
|
||||
|
||||
if (
|
||||
options?.forceRefresh === true &&
|
||||
hasEmptyOrUnloadedMemberPreview(memberNamesRef.current, previewsByMemberRef.current)
|
||||
) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const current = pendingReloadRef.current;
|
||||
pendingReloadRef.current = {
|
||||
forceRefresh: (current?.forceRefresh ?? false) || options?.forceRefresh === true,
|
||||
background: (current?.background ?? true) && options?.background === true,
|
||||
};
|
||||
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
}
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
const pending = pendingReloadRef.current;
|
||||
pendingReloadRef.current = null;
|
||||
void loadPreviewsRef.current({
|
||||
background: pending?.background,
|
||||
forceRefresh: pending?.forceRefresh,
|
||||
});
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearScheduledReload();
|
||||
};
|
||||
}, [clearScheduledReload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
clearScheduledReload();
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
const hasUnloadedPreview = hasUnloadedMemberPreview(memberNames, previewsByMemberRef.current);
|
||||
const laneKeyChanged = laneKeyRef.current !== laneKey;
|
||||
laneKeyRef.current = laneKey;
|
||||
if (hasUnloadedPreview) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
void loadPreviews();
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, loadPreviews, memberKey, memberNames.length]);
|
||||
scheduleReload({ forceRefresh: hasUnloadedPreview || laneKeyChanged });
|
||||
}, [
|
||||
clearScheduledReload,
|
||||
enabled,
|
||||
input.teamName,
|
||||
laneKey,
|
||||
memberKey,
|
||||
memberNames.length,
|
||||
scheduleReload,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const scheduleReload = (forceRefresh: boolean): void => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
if (memberNames.length === 0) return;
|
||||
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
void loadPreviews({ background: true, forceRefresh });
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const unsubscribe = api.teams.onTeamChange?.((_event: unknown, event: TeamChangeEvent) => {
|
||||
if (event.teamName !== input.teamName) return;
|
||||
if (event.type === 'log-source-change') {
|
||||
scheduleReload(true);
|
||||
scheduleReload({ background: true, forceRefresh: true });
|
||||
return;
|
||||
}
|
||||
if (event.type === 'tool-activity') {
|
||||
scheduleReload({ background: true, forceRefresh: true });
|
||||
return;
|
||||
}
|
||||
if (event.type === 'task-log-change') {
|
||||
scheduleReload(true);
|
||||
scheduleReload({ background: true, forceRefresh: true });
|
||||
}
|
||||
});
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === 'visible') scheduleReload(false);
|
||||
if (document.visibilityState === 'visible') {
|
||||
scheduleReload({ background: true, forceRefresh: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
|
|
@ -395,16 +595,12 @@ export function useGraphMemberLogPreviews(input: {
|
|||
}
|
||||
|
||||
return () => {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (typeof unsubscribe === 'function') unsubscribe();
|
||||
};
|
||||
}, [enabled, input.teamName, loadPreviews, memberNames.length]);
|
||||
}, [enabled, input.teamName, scheduleReload]);
|
||||
|
||||
return { previewsByMember, loading, error, reload: loadPreviews };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ import {
|
|||
selectTeamDataForName,
|
||||
selectTeamMessages,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { GRAPH_STABLE_SLOT_LAYOUT_VERSION } from '../../core/domain/graphOwnerIdentity';
|
||||
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
||||
|
||||
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||
|
|
@ -42,7 +44,19 @@ function subscribeNoop(): () => void {
|
|||
}
|
||||
|
||||
function emptyGraphData(teamName: string): GraphDataPort {
|
||||
return { nodes: [], edges: [], particles: [], teamName, isAlive: false };
|
||||
return {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
particles: [],
|
||||
teamName,
|
||||
isAlive: false,
|
||||
layout: {
|
||||
version: GRAPH_STABLE_SLOT_LAYOUT_VERSION,
|
||||
mode: DEFAULT_TEAM_GRAPH_LAYOUT_MODE,
|
||||
ownerOrder: [],
|
||||
slotAssignments: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useTeamGraphAdapter(
|
||||
|
|
@ -191,7 +205,7 @@ export function useTeamGraphAdapter(
|
|||
provisioningProgress,
|
||||
memberSpawnSnapshot,
|
||||
effectiveSlotAssignments,
|
||||
graphLayoutMode ?? 'radial',
|
||||
graphLayoutMode ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE,
|
||||
gridOwnerOrder,
|
||||
activeTaskLogActivity
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
|||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
|
||||
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
|
||||
|
||||
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
|
||||
|
||||
|
|
@ -45,7 +46,7 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
|||
? parseGraphMemberNodeId(payload.displacedNodeId, teamName)
|
||||
: null;
|
||||
const store = useStore.getState();
|
||||
if ((store.graphLayoutModeByTeam[teamName] ?? 'radial') !== 'radial') {
|
||||
if ((store.graphLayoutModeByTeam[teamName] ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE) !== 'radial') {
|
||||
return;
|
||||
}
|
||||
if (displacedStableOwnerId && payload.displacedAssignment) {
|
||||
|
|
@ -72,7 +73,10 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
|||
}
|
||||
|
||||
const store = useStore.getState();
|
||||
if ((store.graphLayoutModeByTeam[teamName] ?? 'radial') !== 'grid-under-lead') {
|
||||
if (
|
||||
(store.graphLayoutModeByTeam[teamName] ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE) !==
|
||||
'grid-under-lead'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,11 +113,11 @@ function resolveEmptyText(
|
|||
loading: boolean,
|
||||
error: string | null
|
||||
): string {
|
||||
if (loading && !preview) return 'Loading logs';
|
||||
if (error && !preview) return 'Logs unavailable';
|
||||
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
|
||||
return 'Unsupported provider';
|
||||
}
|
||||
if (loading && (!preview || preview.items.length === 0)) return 'Loading logs';
|
||||
if (error && (!preview || preview.items.length === 0)) return 'Logs unavailable';
|
||||
return 'No recent logs';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -386,10 +386,24 @@ const MemberPopoverContent = ({
|
|||
: node.state === 'error'
|
||||
? 'bg-red-400'
|
||||
: 'bg-zinc-600');
|
||||
const hasErrorException = node.exceptionTone === 'error';
|
||||
const statusBadgeClass = hasErrorException
|
||||
? 'border-red-500/60 bg-red-500/10 text-red-300 shadow-[0_0_12px_rgba(239,68,68,0.22)]'
|
||||
: '';
|
||||
const showExceptionBadge = node.exceptionLabel && node.exceptionLabel !== statusLabel;
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] max-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
||||
<div
|
||||
className="min-w-[200px] max-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl"
|
||||
style={
|
||||
hasErrorException
|
||||
? {
|
||||
borderColor: 'rgba(239, 68, 68, 0.42)',
|
||||
boxShadow: '0 18px 38px rgba(0, 0, 0, 0.45), 0 0 26px rgba(239, 68, 68, 0.2)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Header: avatar + name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative shrink-0">
|
||||
|
|
@ -422,7 +436,7 @@ const MemberPopoverContent = ({
|
|||
|
||||
{/* Status badges */}
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||
<Badge variant="outline" className={`px-1.5 py-0 text-[10px] ${statusBadgeClass}`}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{node.kind === 'lead' && (
|
||||
|
|
@ -447,7 +461,7 @@ const MemberPopoverContent = ({
|
|||
variant="outline"
|
||||
className={`px-1.5 py-0 text-[10px] ${
|
||||
node.exceptionTone === 'error'
|
||||
? 'border-red-500/30 text-red-400'
|
||||
? 'border-red-500/60 bg-red-500/10 text-red-300 shadow-[0_0_12px_rgba(239,68,68,0.22)]'
|
||||
: 'border-amber-500/30 text-amber-400'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ import {
|
|||
} from '../services/team/memberUpdateNotifications';
|
||||
import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages';
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { TeamConfigReader } from '../services/team/TeamConfigReader';
|
||||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||
|
|
@ -330,6 +331,16 @@ function noteHeavyTeamDataWorkerFallback(operation: string): void {
|
|||
);
|
||||
}
|
||||
|
||||
function invalidateTeamRosterSnapshotCaches(teamName: string): void {
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
const teamDataService = getTeamDataService();
|
||||
teamDataService.invalidateMessageFeed(teamName);
|
||||
teamDataService.invalidateTeamRuntimeAdvisories(teamName);
|
||||
const workerClient = getTeamDataWorkerClient();
|
||||
workerClient.invalidateTeamConfig(teamName);
|
||||
workerClient.invalidateMemberRuntimeAdvisory(teamName);
|
||||
}
|
||||
|
||||
async function getDurableLeadTeammateRoster(
|
||||
teamName: string,
|
||||
leadName: string
|
||||
|
|
@ -1692,6 +1703,9 @@ async function rollbackOpenCodeLiveRosterMutation(options: {
|
|||
previousMembers,
|
||||
previousMembersMeta,
|
||||
});
|
||||
if (metadataRestored) {
|
||||
invalidateTeamRosterSnapshotCaches(teamName);
|
||||
}
|
||||
|
||||
const detachNames = Array.from(
|
||||
new Set(detachOpenCodeMemberNames.map((memberName) => memberName.trim()).filter(Boolean))
|
||||
|
|
@ -2002,10 +2016,15 @@ async function handleCreateTeam(
|
|||
addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName });
|
||||
launchIoGovernor?.noteLaunchIntent(validation.value.teamName, 'create');
|
||||
try {
|
||||
return await getTeamProvisioningService().createTeam(validation.value, (progress) => {
|
||||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
});
|
||||
const response = await getTeamProvisioningService().createTeam(
|
||||
validation.value,
|
||||
(progress) => {
|
||||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
}
|
||||
);
|
||||
invalidateTeamRosterSnapshotCaches(validation.value.teamName);
|
||||
return response;
|
||||
} catch (error) {
|
||||
noteLaunchIntentFailed(validation.value.teamName, 'create');
|
||||
throw error;
|
||||
|
|
@ -2145,10 +2164,15 @@ async function handleLaunchTeam(
|
|||
return wrapTeamHandler('create', async () => {
|
||||
launchIoGovernor?.noteLaunchIntent(tn, 'draft-launch');
|
||||
try {
|
||||
return await getTeamProvisioningService().createTeam(createRequest, (progress) => {
|
||||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
});
|
||||
const response = await getTeamProvisioningService().createTeam(
|
||||
createRequest,
|
||||
(progress) => {
|
||||
launchIoGovernor?.noteProvisioningProgress(progress);
|
||||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
}
|
||||
);
|
||||
invalidateTeamRosterSnapshotCaches(tn);
|
||||
return response;
|
||||
} catch (error) {
|
||||
noteLaunchIntentFailed(tn, 'draft-launch');
|
||||
throw error;
|
||||
|
|
@ -2195,7 +2219,7 @@ async function handleLaunchTeam(
|
|||
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||||
launchIoGovernor?.noteLaunchIntent(validatedTeamName.value!, 'launch');
|
||||
try {
|
||||
return await getTeamProvisioningService().launchTeam(
|
||||
const response = await getTeamProvisioningService().launchTeam(
|
||||
{
|
||||
teamName: validatedTeamName.value!,
|
||||
cwd,
|
||||
|
|
@ -2222,6 +2246,8 @@ async function handleLaunchTeam(
|
|||
sendProvisioningProgress(progressTargetWindow, progress);
|
||||
}
|
||||
);
|
||||
invalidateTeamRosterSnapshotCaches(validatedTeamName.value!);
|
||||
return response;
|
||||
} catch (error) {
|
||||
noteLaunchIntentFailed(validatedTeamName.value!, 'launch');
|
||||
throw error;
|
||||
|
|
@ -4182,6 +4208,7 @@ async function handleAddMember(
|
|||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
});
|
||||
invalidateTeamRosterSnapshotCaches(tn);
|
||||
|
||||
// If team is alive, notify the lead to spawn the new teammate
|
||||
if (isTeamAlive) {
|
||||
|
|
@ -4401,7 +4428,7 @@ async function handleReplaceMembers(
|
|||
: [];
|
||||
|
||||
await teamDataService.replaceMembers(tn, { members });
|
||||
teamDataService.invalidateMessageFeed(tn);
|
||||
invalidateTeamRosterSnapshotCaches(tn);
|
||||
|
||||
if (!isTeamAlive) {
|
||||
return;
|
||||
|
|
@ -4499,6 +4526,7 @@ async function handleRemoveMember(
|
|||
(member) => member.name.trim().toLowerCase() === name.trim().toLowerCase()
|
||||
);
|
||||
await teamDataService.removeMember(tn, name);
|
||||
invalidateTeamRosterSnapshotCaches(tn);
|
||||
|
||||
// Notify the lead about removed member
|
||||
if (isTeamAlive) {
|
||||
|
|
@ -4614,6 +4642,7 @@ async function handleUpdateMemberRole(
|
|||
);
|
||||
|
||||
if (changed) {
|
||||
invalidateTeamRosterSnapshotCaches(tn);
|
||||
const provisioning = getTeamProvisioningService();
|
||||
if (provisioning.isTeamAlive(tn)) {
|
||||
const oldDesc = oldRole ? `"${oldRole}"` : 'none';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
type OpenCodePromptDeliveryLedgerRecord,
|
||||
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
|
||||
import {
|
||||
classifyOpenCodeRuntimeDeliveryReasonCode,
|
||||
decideOpenCodeRuntimeDeliveryAdvisory,
|
||||
getOpenCodeRuntimeDeliveryRecordTimeMs,
|
||||
isPotentialOpenCodeRuntimeDeliveryError,
|
||||
|
|
@ -21,6 +20,7 @@ import {
|
|||
getOpenCodeLaneScopedRuntimeFilePath,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
import { classifyRuntimeDiagnostic } from './runtime/RuntimeDiagnosticClassifier';
|
||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
|
||||
import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
||||
|
|
@ -701,7 +701,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
observedAt: new Date(observedAt).toISOString(),
|
||||
retryUntil: new Date(retryUntil).toISOString(),
|
||||
retryDelayMs: retryInMs,
|
||||
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(message),
|
||||
reasonCode: classifyRuntimeDiagnostic(message).reasonCode,
|
||||
...(message ? { message } : {}),
|
||||
};
|
||||
} catch {
|
||||
|
|
@ -753,7 +753,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
return {
|
||||
kind: 'api_error',
|
||||
observedAt: new Date(observedAt).toISOString(),
|
||||
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(message || parsed.error),
|
||||
reasonCode: classifyRuntimeDiagnostic(message || parsed.error).reasonCode,
|
||||
...(message ? { message } : {}),
|
||||
...(statusMatch ? { statusCode: Number(statusMatch[1]) } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {
|
||||
isActionRequiredOpenCodeRuntimeDeliveryReason,
|
||||
normalizeOpenCodeRuntimeDeliveryDiagnostic,
|
||||
selectOpenCodeRuntimeDeliveryReason,
|
||||
} from './OpenCodeRuntimeDeliveryDiagnostics';
|
||||
import { classifyRuntimeDiagnostic } from '../../runtime/RuntimeDiagnosticClassifier';
|
||||
|
||||
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
|
||||
import type {
|
||||
|
|
@ -32,83 +32,6 @@ export interface OpenCodeRuntimeDeliveryAdvisoryDecision {
|
|||
nextReviewAt?: string;
|
||||
}
|
||||
|
||||
const QUOTA_EXHAUSTED_TOKENS = [
|
||||
'exhausted your capacity',
|
||||
'capacity exceeded',
|
||||
'quota exceeded',
|
||||
'quota exhausted',
|
||||
'insufficient credits',
|
||||
'key limit exceeded',
|
||||
'total limit',
|
||||
] as const;
|
||||
const RATE_LIMITED_TOKENS = [
|
||||
'rate limit',
|
||||
'too many requests',
|
||||
'429',
|
||||
'model cooldown',
|
||||
'cooling down',
|
||||
] as const;
|
||||
const AUTH_ERROR_TOKENS = [
|
||||
'auth_unavailable',
|
||||
'no auth available',
|
||||
'authentication_failed',
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'invalid api key',
|
||||
'authentication',
|
||||
'api key',
|
||||
'does not have access',
|
||||
'please run /login',
|
||||
] as const;
|
||||
const CODEX_NATIVE_TIMEOUT_TOKENS = ['codex native exec timed out'] as const;
|
||||
const NETWORK_ERROR_TOKENS = [
|
||||
'timeout',
|
||||
'timed out',
|
||||
'network',
|
||||
'connection',
|
||||
'econn',
|
||||
'enotfound',
|
||||
'fetch failed',
|
||||
] as const;
|
||||
const PROVIDER_OVERLOADED_TOKENS = [
|
||||
'overloaded',
|
||||
'temporarily unavailable',
|
||||
'service unavailable',
|
||||
'503',
|
||||
] as const;
|
||||
const PROTOCOL_PROOF_MISSING_TOKENS = [
|
||||
'non_visible_tool_without_task_progress',
|
||||
'visible_reply_still_required',
|
||||
'visible_reply_ack_only_still_requires_answer',
|
||||
'plain_text_ack_only_still_requires_answer',
|
||||
'visible_reply_destination_not_found_yet',
|
||||
'visible_reply_missing_relayofmessageid',
|
||||
'visible_reply_missing_task_refs',
|
||||
'visible_reply_missing_task_refs_after_merge',
|
||||
'visible_reply_task_refs_merge_failed',
|
||||
'did not create a visible reply',
|
||||
'did not create a visible message_send reply',
|
||||
'did not create a visible reply or task progress proof',
|
||||
'without the required relayofmessageid correlation',
|
||||
'without the required taskrefs metadata',
|
||||
'could not be verified',
|
||||
'no visible reply has been found yet',
|
||||
] as const;
|
||||
const DEFERRED_GENERIC_DELIVERY_TOKENS = [
|
||||
...PROTOCOL_PROOF_MISSING_TOKENS,
|
||||
'empty_assistant_turn',
|
||||
'empty assistant turn',
|
||||
'prompt_delivered_no_assistant_message',
|
||||
'accepted the prompt, but no assistant turn was recorded',
|
||||
'opencode runtime delivery did not complete',
|
||||
'opencode message delivery observe bridge failed',
|
||||
'opencode bridge command timed out',
|
||||
'opencode app mcp was reattached before message delivery',
|
||||
'reattached stale opencode app mcp server',
|
||||
'recreated opencode session before message delivery',
|
||||
'opencode session reconcile skipped because the stored session is stale',
|
||||
] as const;
|
||||
|
||||
const HARD_RUNTIME_RESPONSE_STATES = new Set([
|
||||
'session_error',
|
||||
'tool_error',
|
||||
|
|
@ -116,43 +39,10 @@ const HARD_RUNTIME_RESPONSE_STATES = new Set([
|
|||
'reconcile_failed',
|
||||
]);
|
||||
|
||||
function includesAnyToken(value: string, tokens: readonly string[]): boolean {
|
||||
return tokens.some((token) => value.includes(token));
|
||||
}
|
||||
|
||||
function normalizeForClassification(message: string | null | undefined): string {
|
||||
return normalizeOpenCodeRuntimeDeliveryDiagnostic(message)?.toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
export function classifyOpenCodeRuntimeDeliveryReasonCode(
|
||||
message: string | undefined
|
||||
): MemberRuntimeAdvisory['reasonCode'] {
|
||||
const normalized = normalizeForClassification(message);
|
||||
if (!normalized) {
|
||||
return 'unknown';
|
||||
}
|
||||
if (includesAnyToken(normalized, QUOTA_EXHAUSTED_TOKENS)) {
|
||||
return 'quota_exhausted';
|
||||
}
|
||||
if (includesAnyToken(normalized, RATE_LIMITED_TOKENS)) {
|
||||
return 'rate_limited';
|
||||
}
|
||||
if (includesAnyToken(normalized, AUTH_ERROR_TOKENS)) {
|
||||
return 'auth_error';
|
||||
}
|
||||
if (includesAnyToken(normalized, CODEX_NATIVE_TIMEOUT_TOKENS)) {
|
||||
return 'codex_native_timeout';
|
||||
}
|
||||
if (includesAnyToken(normalized, NETWORK_ERROR_TOKENS)) {
|
||||
return 'network_error';
|
||||
}
|
||||
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
|
||||
return 'provider_overloaded';
|
||||
}
|
||||
if (includesAnyToken(normalized, PROTOCOL_PROOF_MISSING_TOKENS)) {
|
||||
return 'protocol_proof_missing';
|
||||
}
|
||||
return 'backend_error';
|
||||
return classifyRuntimeDiagnostic(message).reasonCode;
|
||||
}
|
||||
|
||||
export function getOpenCodeRuntimeDeliveryRecordTimeMs(
|
||||
|
|
@ -222,8 +112,8 @@ export function isProofOnlyOpenCodeRuntimeDeliveryReason(
|
|||
export function isDeferredGenericOpenCodeRuntimeDeliveryReason(
|
||||
reason: string | null | undefined
|
||||
): boolean {
|
||||
const normalized = normalizeForClassification(reason);
|
||||
return Boolean(normalized) && includesAnyToken(normalized, DEFERRED_GENERIC_DELIVERY_TOKENS);
|
||||
const classification = classifyRuntimeDiagnostic(reason);
|
||||
return Boolean(classification.normalizedMessage) && classification.generic;
|
||||
}
|
||||
|
||||
export function isHardOpenCodeRuntimeDeliveryReason(input: {
|
||||
|
|
|
|||
|
|
@ -1,85 +1,28 @@
|
|||
import {
|
||||
classifyRuntimeDiagnostic,
|
||||
selectRuntimeDiagnosticClassification,
|
||||
} from '../../runtime/RuntimeDiagnosticClassifier';
|
||||
|
||||
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
|
||||
|
||||
const SECRET_VALUE_PATTERNS = [
|
||||
/\bsk-[A-Z0-9_-]{12,}\b/gi,
|
||||
/\b[A-Z0-9_-]*api[_-]?key[A-Z0-9_-]*[=:]\s*['"]?[^'"\s]+/gi,
|
||||
/\bauthorization:\s*bearer\s+[^'"\s]+/gi,
|
||||
] as const;
|
||||
|
||||
const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [
|
||||
'opencode app mcp was reattached before message delivery',
|
||||
'reattached stale opencode app mcp server',
|
||||
'opencode session reconcile skipped because the stored session is stale',
|
||||
'recreated opencode session before message delivery',
|
||||
'opencode message delivery observe bridge failed',
|
||||
'opencode bridge command timed out',
|
||||
'opencode bootstrap mcp did not complete required tools before assistant response',
|
||||
'existing app mcp config does not expose environment',
|
||||
'empty_assistant_turn',
|
||||
'visible_reply_still_required',
|
||||
'prompt_delivered_no_assistant_message',
|
||||
'plain_text_ack_only_still_requires_answer',
|
||||
'visible_reply_ack_only_still_requires_answer',
|
||||
'visible_reply_destination_not_found_yet',
|
||||
'visible_reply_missing_relayofmessageid',
|
||||
'visible_reply_missing_task_refs',
|
||||
'visible_reply_missing_task_refs_after_merge',
|
||||
'visible_reply_task_refs_merge_failed',
|
||||
'opencode_runtime_delivery_task_refs_inherited_from_relay',
|
||||
'non_visible_tool_without_task_progress',
|
||||
] as const;
|
||||
|
||||
const ACTION_REQUIRED_DELIVERY_ERROR_TOKENS = [
|
||||
'auth_unavailable',
|
||||
'no auth available',
|
||||
'authentication_failed',
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'invalid api key',
|
||||
'api key',
|
||||
'does not have access',
|
||||
'please run /login',
|
||||
'insufficient credits',
|
||||
'quota exceeded',
|
||||
'quota exhausted',
|
||||
'capacity exceeded',
|
||||
'key limit exceeded',
|
||||
'total limit',
|
||||
] as const;
|
||||
|
||||
export function normalizeOpenCodeRuntimeDeliveryDiagnostic(
|
||||
message: string | null | undefined
|
||||
): string | null {
|
||||
const scrubbed = SECRET_VALUE_PATTERNS.reduce(
|
||||
(current, pattern) => current.replace(pattern, '[redacted]'),
|
||||
message ?? ''
|
||||
);
|
||||
const normalized = scrubbed
|
||||
?.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '')
|
||||
.replace(/^APIError\s*[-:]\s*/i, '');
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
return classifyRuntimeDiagnostic(message).normalizedMessage;
|
||||
}
|
||||
|
||||
export function isGenericOpenCodeRuntimeDeliveryDiagnostic(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return GENERIC_DELIVERY_DIAGNOSTIC_TOKENS.some((token) => normalized.includes(token));
|
||||
return classifyRuntimeDiagnostic(message).generic;
|
||||
}
|
||||
|
||||
export function selectOpenCodeRuntimeDeliveryReason(
|
||||
record: OpenCodePromptDeliveryLedgerRecord
|
||||
): string | null {
|
||||
const candidates = [...record.diagnostics.slice().reverse(), record.lastReason];
|
||||
const normalized = candidates.flatMap((candidate) => {
|
||||
const message = normalizeOpenCodeRuntimeDeliveryDiagnostic(candidate);
|
||||
return message ? [message] : [];
|
||||
});
|
||||
const specific = normalized.find(
|
||||
(message) => !isGenericOpenCodeRuntimeDeliveryDiagnostic(message)
|
||||
);
|
||||
if (specific) {
|
||||
return boundOpenCodeRuntimeDeliveryReason(specific);
|
||||
const selected = selectRuntimeDiagnosticClassification(candidates);
|
||||
|
||||
if (selected && !selected.generic && selected.normalizedMessage) {
|
||||
return boundOpenCodeRuntimeDeliveryReason(selected.normalizedMessage);
|
||||
}
|
||||
|
||||
const fallback = getOpenCodeRuntimeDeliveryStateFallback(record);
|
||||
|
|
@ -87,17 +30,13 @@ export function selectOpenCodeRuntimeDeliveryReason(
|
|||
return fallback;
|
||||
}
|
||||
|
||||
return normalized.length > 0 ? 'OpenCode runtime delivery did not complete.' : null;
|
||||
return selected ? 'OpenCode runtime delivery did not complete.' : null;
|
||||
}
|
||||
|
||||
export function isActionRequiredOpenCodeRuntimeDeliveryReason(
|
||||
message: string | null | undefined
|
||||
): boolean {
|
||||
const normalized = normalizeOpenCodeRuntimeDeliveryDiagnostic(message)?.toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return ACTION_REQUIRED_DELIVERY_ERROR_TOKENS.some((token) => normalized.includes(token));
|
||||
return classifyRuntimeDiagnostic(message).actionRequired;
|
||||
}
|
||||
|
||||
function getOpenCodeRuntimeDeliveryStateFallback(
|
||||
|
|
|
|||
235
src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts
Normal file
235
src/main/services/team/runtime/RuntimeDiagnosticClassifier.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import type { MemberRuntimeAdvisory } from '@shared/types';
|
||||
|
||||
export interface RuntimeDiagnosticClassification {
|
||||
reasonCode: NonNullable<MemberRuntimeAdvisory['reasonCode']>;
|
||||
normalizedMessage: string | null;
|
||||
priority: number;
|
||||
actionRequired: boolean;
|
||||
generic: boolean;
|
||||
}
|
||||
|
||||
interface RuntimeDiagnosticRule {
|
||||
reasonCode: RuntimeDiagnosticClassification['reasonCode'];
|
||||
tokens: readonly string[];
|
||||
priority: number;
|
||||
actionRequired?: boolean;
|
||||
generic?: boolean;
|
||||
normalizeMessage?: (message: string) => string;
|
||||
}
|
||||
|
||||
const SECRET_VALUE_PATTERNS = [
|
||||
/\bsk-[A-Z0-9_-]{12,}\b/gi,
|
||||
/\b[A-Z0-9_-]*api[_-]?key[A-Z0-9_-]*[=:]\s*['"]?[^'"\s]+/gi,
|
||||
/\bauthorization:\s*bearer\s+[^'"\s]+/gi,
|
||||
] as const;
|
||||
|
||||
const DISK_FULL_MESSAGE =
|
||||
'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.';
|
||||
|
||||
const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
|
||||
{
|
||||
reasonCode: 'filesystem_error',
|
||||
tokens: ['enospc', 'no space left on device', 'disk is full', 'local disk is full'],
|
||||
priority: 100,
|
||||
actionRequired: true,
|
||||
normalizeMessage: () => DISK_FULL_MESSAGE,
|
||||
},
|
||||
{
|
||||
reasonCode: 'quota_exhausted',
|
||||
tokens: [
|
||||
'exhausted your capacity',
|
||||
'capacity exceeded',
|
||||
'quota exceeded',
|
||||
'quota exhausted',
|
||||
'insufficient credits',
|
||||
'key limit exceeded',
|
||||
'total limit',
|
||||
],
|
||||
priority: 95,
|
||||
actionRequired: true,
|
||||
},
|
||||
{
|
||||
reasonCode: 'auth_error',
|
||||
tokens: [
|
||||
'auth_unavailable',
|
||||
'no auth available',
|
||||
'authentication_failed',
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'invalid api key',
|
||||
'authentication',
|
||||
'api key',
|
||||
'does not have access',
|
||||
'please run /login',
|
||||
],
|
||||
priority: 94,
|
||||
actionRequired: true,
|
||||
},
|
||||
{
|
||||
reasonCode: 'rate_limited',
|
||||
tokens: ['rate limit', 'too many requests', '429', 'model cooldown', 'cooling down'],
|
||||
priority: 85,
|
||||
},
|
||||
{
|
||||
reasonCode: 'codex_native_timeout',
|
||||
tokens: ['codex native exec timed out'],
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
reasonCode: 'backend_error',
|
||||
tokens: ['opencode bridge command timed out'],
|
||||
priority: 20,
|
||||
generic: true,
|
||||
},
|
||||
{
|
||||
reasonCode: 'network_error',
|
||||
tokens: ['timeout', 'timed out', 'network', 'connection', 'econn', 'enotfound', 'fetch failed'],
|
||||
priority: 70,
|
||||
},
|
||||
{
|
||||
reasonCode: 'provider_overloaded',
|
||||
tokens: ['overloaded', 'temporarily unavailable', 'service unavailable', '503'],
|
||||
priority: 65,
|
||||
},
|
||||
{
|
||||
reasonCode: 'protocol_proof_missing',
|
||||
tokens: [
|
||||
'non_visible_tool_without_task_progress',
|
||||
'visible_reply_still_required',
|
||||
'visible_reply_ack_only_still_requires_answer',
|
||||
'plain_text_ack_only_still_requires_answer',
|
||||
'visible_reply_destination_not_found_yet',
|
||||
'visible_reply_missing_relayofmessageid',
|
||||
'visible_reply_missing_task_refs',
|
||||
'visible_reply_missing_task_refs_after_merge',
|
||||
'visible_reply_task_refs_merge_failed',
|
||||
'did not create a visible reply',
|
||||
'did not create a visible message_send reply',
|
||||
'did not create a visible reply or task progress proof',
|
||||
'without the required relayofmessageid correlation',
|
||||
'without the required taskrefs metadata',
|
||||
'could not be verified',
|
||||
'no visible reply has been found yet',
|
||||
],
|
||||
priority: 60,
|
||||
generic: true,
|
||||
},
|
||||
{
|
||||
reasonCode: 'backend_error',
|
||||
tokens: [
|
||||
'empty_assistant_turn',
|
||||
'empty assistant turn',
|
||||
'prompt_delivered_no_assistant_message',
|
||||
'accepted the prompt, but no assistant turn was recorded',
|
||||
'opencode runtime delivery did not complete',
|
||||
'opencode message delivery observe bridge failed',
|
||||
'opencode bridge command timed out',
|
||||
'opencode app mcp was reattached before message delivery',
|
||||
'reattached stale opencode app mcp server',
|
||||
'recreated opencode session before message delivery',
|
||||
'opencode session reconcile skipped because the stored session is stale',
|
||||
'opencode bootstrap mcp did not complete required tools before assistant response',
|
||||
'existing app mcp config does not expose environment',
|
||||
'messageabortederror',
|
||||
'aborted',
|
||||
'bridge stdout was empty',
|
||||
],
|
||||
priority: 20,
|
||||
generic: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const UNKNOWN_CLASSIFICATION: RuntimeDiagnosticClassification = {
|
||||
reasonCode: 'unknown',
|
||||
normalizedMessage: null,
|
||||
priority: 0,
|
||||
actionRequired: false,
|
||||
generic: true,
|
||||
};
|
||||
|
||||
function stripLatestAssistantFailurePrefix(message: string): string {
|
||||
const marker = ' failed with ';
|
||||
const lowerMessage = message.toLowerCase();
|
||||
const markerIndex = lowerMessage.indexOf(marker);
|
||||
if (!lowerMessage.startsWith('latest assistant message ') || markerIndex < 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const errorNameStart = markerIndex + marker.length;
|
||||
const dashIndex = message.indexOf('-', errorNameStart);
|
||||
const colonIndex = message.indexOf(':', errorNameStart);
|
||||
const separatorIndexes = [dashIndex, colonIndex]
|
||||
.filter((index) => index >= 0)
|
||||
.sort((left, right) => left - right);
|
||||
if (separatorIndexes.length === 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const separatorIndex = separatorIndexes[0];
|
||||
const errorName = message.slice(errorNameStart, separatorIndex).trim();
|
||||
if (!/^[A-Za-z][A-Za-z0-9_.]*Error$/.test(errorName)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return message.slice(separatorIndex + 1).trimStart();
|
||||
}
|
||||
|
||||
export function normalizeRuntimeDiagnosticMessage(
|
||||
message: string | null | undefined
|
||||
): string | null {
|
||||
const scrubbed = SECRET_VALUE_PATTERNS.reduce(
|
||||
(current, pattern) => current.replace(pattern, '[redacted]'),
|
||||
message ?? ''
|
||||
);
|
||||
const normalized = stripLatestAssistantFailurePrefix(
|
||||
scrubbed.replace(/\s+/g, ' ').trim()
|
||||
).replace(/^APIError\s*[-:]\s*/i, '');
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function classifyRuntimeDiagnostic(
|
||||
message: string | null | undefined
|
||||
): RuntimeDiagnosticClassification {
|
||||
const normalizedMessage = normalizeRuntimeDiagnosticMessage(message);
|
||||
if (!normalizedMessage) {
|
||||
return { ...UNKNOWN_CLASSIFICATION };
|
||||
}
|
||||
|
||||
const normalized = normalizedMessage.toLowerCase();
|
||||
const rule = RUNTIME_DIAGNOSTIC_RULES.find((candidate) =>
|
||||
candidate.tokens.some((token) => normalized.includes(token))
|
||||
);
|
||||
if (!rule) {
|
||||
return {
|
||||
reasonCode: 'backend_error',
|
||||
normalizedMessage,
|
||||
priority: 50,
|
||||
actionRequired: false,
|
||||
generic: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reasonCode: rule.reasonCode,
|
||||
normalizedMessage: rule.normalizeMessage?.(normalizedMessage) ?? normalizedMessage,
|
||||
priority: rule.priority,
|
||||
actionRequired: rule.actionRequired === true,
|
||||
generic: rule.generic === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectRuntimeDiagnosticClassification(
|
||||
messages: readonly (string | null | undefined)[]
|
||||
): RuntimeDiagnosticClassification | null {
|
||||
let selected: RuntimeDiagnosticClassification | null = null;
|
||||
for (const message of messages) {
|
||||
const classified = classifyRuntimeDiagnostic(message);
|
||||
if (!classified.normalizedMessage) {
|
||||
continue;
|
||||
}
|
||||
if (!selected || classified.priority > selected.priority) {
|
||||
selected = classified;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
|
@ -28,23 +28,56 @@ interface RenderedTeamChangeSummary {
|
|||
fileBudget: number;
|
||||
}
|
||||
|
||||
function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] {
|
||||
if (!Array.isArray(changeSet?.files)) {
|
||||
return [];
|
||||
}
|
||||
return changeSet.files.filter((file): file is FileChangeSummary =>
|
||||
Boolean(
|
||||
file &&
|
||||
typeof file === 'object' &&
|
||||
typeof (file as Partial<FileChangeSummary>).filePath === 'string'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getChangeSetWarnings(changeSet: TaskChangeSetV2): string[] {
|
||||
return Array.isArray(changeSet.warnings)
|
||||
? changeSet.warnings.filter((warning): warning is string => typeof warning === 'string')
|
||||
: [];
|
||||
}
|
||||
|
||||
function getTaskChangeContributors(
|
||||
task: TeamTaskWithKanban,
|
||||
changeSet: TaskChangeSetV2 | null
|
||||
): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const contributor of changeSet?.scope.contributors ?? []) {
|
||||
if (contributor.memberName) names.add(contributor.memberName);
|
||||
const contributors = Array.isArray(changeSet?.scope?.contributors)
|
||||
? changeSet.scope.contributors
|
||||
: [];
|
||||
for (const contributor of contributors) {
|
||||
const memberName =
|
||||
contributor && typeof contributor.memberName === 'string' ? contributor.memberName : '';
|
||||
if (memberName) names.add(memberName);
|
||||
}
|
||||
for (const name of changeSet?.scope.memberNames ?? []) {
|
||||
names.add(name);
|
||||
const memberNames = Array.isArray(changeSet?.scope?.memberNames)
|
||||
? changeSet.scope.memberNames
|
||||
: [];
|
||||
for (const name of memberNames) {
|
||||
if (typeof name === 'string' && name) names.add(name);
|
||||
}
|
||||
if (changeSet?.scope.primaryMemberName) {
|
||||
if (
|
||||
typeof changeSet?.scope?.primaryMemberName === 'string' &&
|
||||
changeSet.scope.primaryMemberName
|
||||
) {
|
||||
names.add(changeSet.scope.primaryMemberName);
|
||||
}
|
||||
for (const file of changeSet?.files ?? []) {
|
||||
for (const name of file.ledgerSummary?.memberNames ?? []) {
|
||||
names.add(name);
|
||||
for (const file of getChangeSetFiles(changeSet)) {
|
||||
const fileMemberNames = Array.isArray(file.ledgerSummary?.memberNames)
|
||||
? file.ledgerSummary.memberNames
|
||||
: [];
|
||||
for (const name of fileMemberNames) {
|
||||
if (typeof name === 'string' && name) names.add(name);
|
||||
}
|
||||
}
|
||||
if (names.size === 0 && task.owner) {
|
||||
|
|
@ -54,10 +87,16 @@ function getTaskChangeContributors(
|
|||
}
|
||||
|
||||
function getVisibleFileName(file: FileChangeSummary): string {
|
||||
const value = file.relativePath || file.filePath;
|
||||
const value = getVisibleFilePath(file);
|
||||
return value.split(/[\\/]/).pop() ?? value;
|
||||
}
|
||||
|
||||
function getVisibleFilePath(file: FileChangeSummary): string {
|
||||
return typeof file.relativePath === 'string' && file.relativePath.trim() !== ''
|
||||
? file.relativePath
|
||||
: file.filePath;
|
||||
}
|
||||
|
||||
function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
|
||||
if (!changeSet) return undefined;
|
||||
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
|
||||
|
|
@ -75,7 +114,7 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
|
|||
const messages =
|
||||
status.diagnostics.length > 0
|
||||
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
||||
: changeSet.warnings;
|
||||
: getChangeSetWarnings(changeSet);
|
||||
return [...new Set(messages.filter((message) => message.trim().length > 0))];
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +141,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
return (
|
||||
Boolean(entry.task) &&
|
||||
(Boolean(entry.summary.error) ||
|
||||
(changeSet?.files.length ?? 0) > 0 ||
|
||||
getChangeSetFiles(changeSet).length > 0 ||
|
||||
(changeSet ? getTaskChangeDiagnosticMessages(changeSet).length > 0 : false))
|
||||
);
|
||||
})
|
||||
|
|
@ -110,7 +149,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
}, [summariesByTaskId, taskMap]);
|
||||
|
||||
const totalFiles = visibleSummaries.reduce(
|
||||
(sum, entry) => sum + (entry.summary.changeSet?.files.length ?? 0),
|
||||
(sum, entry) => sum + getChangeSetFiles(entry.summary.changeSet).length,
|
||||
0
|
||||
);
|
||||
const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS);
|
||||
|
|
@ -119,7 +158,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
const entries: RenderedTeamChangeSummary[] = [];
|
||||
let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
|
||||
for (const entry of visibleSummaries) {
|
||||
const files = entry.summary.changeSet?.files ?? [];
|
||||
const files = getChangeSetFiles(entry.summary.changeSet);
|
||||
const fileBudget = Math.max(0, remainingFileRows);
|
||||
const visibleFiles = files.slice(0, fileBudget);
|
||||
entries.push({ ...entry, visibleFiles, fileBudget });
|
||||
|
|
@ -167,19 +206,12 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
}
|
||||
contentClassName="pl-2.5"
|
||||
>
|
||||
{loading && visibleSummaries.length === 0 ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading changes...
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
) : visibleSummaries.length > 0 ? (
|
||||
{visibleSummaries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto pr-1">
|
||||
{renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => {
|
||||
const changeSet = summary.changeSet;
|
||||
const files = changeSet?.files ?? [];
|
||||
const files = getChangeSetFiles(changeSet);
|
||||
const reviewability = changeSet
|
||||
? classifyTaskChangeReviewability(changeSet).reviewability
|
||||
: 'unknown';
|
||||
|
|
@ -267,9 +299,9 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
|
||||
onClick={() => onViewChanges(task.id, file.filePath)}
|
||||
title={file.relativePath || file.filePath}
|
||||
title={getVisibleFilePath(file)}
|
||||
>
|
||||
{file.relativePath || file.filePath}
|
||||
{getVisibleFilePath(file)}
|
||||
</button>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{file.linesAdded > 0 ? (
|
||||
|
|
@ -310,18 +342,26 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
{refreshing ? (
|
||||
{loading || refreshing ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
Refreshing
|
||||
</span>
|
||||
) : null}
|
||||
{error ? <span className="text-red-400">Refresh failed: {error}</span> : null}
|
||||
{hiddenFileRows > 0 ? <span>{hiddenFileRows} file rows hidden</span> : null}
|
||||
{stats.deferredCount > 0 ? (
|
||||
<span>{stats.deferredCount} tasks deferred this pass</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : loading || refreshing ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{loading ? 'Loading changes...' : 'Refreshing changes...'}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
) : (
|
||||
<div className="space-y-1 py-1">
|
||||
<p className="text-xs text-[var(--color-text-muted)]">No file changes recorded</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,618 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import { TooltipProvider } from '@renderer/components/ui/tooltip';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TEAM_CHANGES_LOAD_TIMEOUT_MS } from '../teamChangesLoadTimeout';
|
||||
import { TeamChangesSection } from '../TeamChangesSection';
|
||||
import { type TeamChangeSummaryState, useTeamChangesSummaries } from '../useTeamChangesSummaries';
|
||||
|
||||
import type {
|
||||
TaskChangeSetV2,
|
||||
TeamTaskChangeSummariesResponse,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
getTeamTaskChangeSummaries: vi.fn(),
|
||||
recordTaskChangePresence: vi.fn(),
|
||||
setSelectedTeamTaskChangePresence: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
review: {
|
||||
getTeamTaskChangeSummaries: hoisted.getTeamTaskChangeSummaries,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
recordTaskChangePresence: hoisted.recordTaskChangePresence,
|
||||
setSelectedTeamTaskChangePresence: hoisted.setSelectedTeamTaskChangePresence,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function task(overrides: Partial<TeamTaskWithKanban> = {}): TeamTaskWithKanban {
|
||||
return {
|
||||
id: 'task-1',
|
||||
subject: 'Task 1',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
createdAt: '2026-05-10T10:00:00.000Z',
|
||||
updatedAt: '2026-05-10T10:00:00.000Z',
|
||||
changePresence: 'unknown',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function changeSet(taskId = 'task-1'): TaskChangeSetV2 {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
taskId,
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
confidence: 'high',
|
||||
computedAt: '2026-05-10T10:00:00.000Z',
|
||||
scope: {
|
||||
taskId,
|
||||
memberName: 'alice',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '2026-05-10T10:00:00.000Z',
|
||||
endTimestamp: '2026-05-10T10:01:00.000Z',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 1, label: 'high', reason: 'test' },
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function fileChange(
|
||||
overrides: Partial<TaskChangeSetV2['files'][number]> = {}
|
||||
): TaskChangeSetV2['files'][number] {
|
||||
return {
|
||||
filePath: '/repo/src/app.ts',
|
||||
relativePath: 'src/app.ts',
|
||||
snippets: [],
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
isNewFile: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function response(summary: TaskChangeSetV2 = changeSet()): TeamTaskChangeSummariesResponse {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
computedAt: '2026-05-10T10:00:01.000Z',
|
||||
items: [{ taskId: 'task-1', changeSet: summary }],
|
||||
};
|
||||
}
|
||||
|
||||
function malformedLegacyChangeSet(): TaskChangeSetV2 {
|
||||
return {
|
||||
...changeSet(),
|
||||
files: undefined,
|
||||
scope: undefined,
|
||||
totalFiles: 1,
|
||||
warnings: ['legacy warning'],
|
||||
} as unknown as TaskChangeSetV2;
|
||||
}
|
||||
|
||||
function malformedResponse(): TeamTaskChangeSummariesResponse {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
computedAt: '2026-05-10T10:00:01.000Z',
|
||||
items: undefined,
|
||||
} as unknown as TeamTaskChangeSummariesResponse;
|
||||
}
|
||||
|
||||
function malformedItemResponse(): TeamTaskChangeSummariesResponse {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
computedAt: '2026-05-10T10:00:01.000Z',
|
||||
items: [
|
||||
{
|
||||
taskId: ' task-1 ',
|
||||
changeSet: 'not-a-change-set',
|
||||
error: { message: 'not a string' },
|
||||
},
|
||||
],
|
||||
} as unknown as TeamTaskChangeSummariesResponse;
|
||||
}
|
||||
|
||||
function incompleteChangeSetResponse(): TeamTaskChangeSummariesResponse {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
computedAt: '2026-05-10T10:00:01.000Z',
|
||||
items: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
changeSet: {
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-1',
|
||||
files: [],
|
||||
warnings: [],
|
||||
confidence: 'high',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as TeamTaskChangeSummariesResponse;
|
||||
}
|
||||
|
||||
function lowConfidenceFileResponse(): TeamTaskChangeSummariesResponse {
|
||||
return response({
|
||||
...changeSet(),
|
||||
confidence: 'low',
|
||||
files: [fileChange()],
|
||||
totalFiles: 1,
|
||||
totalLinesAdded: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidFileSummaryResponse(): TeamTaskChangeSummariesResponse {
|
||||
return response({
|
||||
...changeSet(),
|
||||
confidence: 'low',
|
||||
files: [{} as TaskChangeSetV2['files'][number]],
|
||||
totalFiles: 1,
|
||||
totalLinesAdded: 1,
|
||||
});
|
||||
}
|
||||
|
||||
interface HookSnapshot {
|
||||
loading: boolean;
|
||||
refreshing: boolean;
|
||||
error: string | null;
|
||||
summariesByTaskId: Record<string, TeamChangeSummaryState>;
|
||||
}
|
||||
|
||||
const HookHarness = ({
|
||||
tasks,
|
||||
onSnapshot,
|
||||
}: {
|
||||
tasks: TeamTaskWithKanban[];
|
||||
onSnapshot: (snapshot: HookSnapshot) => void;
|
||||
}): null => {
|
||||
const state = useTeamChangesSummaries({
|
||||
teamName: 'team-a',
|
||||
tasks,
|
||||
sectionOpen: true,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
onSnapshot({
|
||||
loading: state.loading,
|
||||
refreshing: state.refreshing,
|
||||
error: state.error,
|
||||
summariesByTaskId: state.summariesByTaskId,
|
||||
});
|
||||
}, [onSnapshot, state.error, state.loading, state.refreshing, state.summariesByTaskId]);
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('useTeamChangesSummaries', () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
container = null;
|
||||
root = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not keep initial loading stuck when tasks change during an active request', async () => {
|
||||
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
hoisted.getTeamTaskChangeSummaries
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
const snapshots: HookSnapshot[] = [];
|
||||
const onSnapshot = (snapshot: HookSnapshot): void => {
|
||||
snapshots.push(snapshot);
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
|
||||
onSnapshot,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(response());
|
||||
await first.promise;
|
||||
});
|
||||
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
||||
expect(snapshots.at(-1)?.loading).toBe(false);
|
||||
expect(snapshots.at(-1)?.refreshing).toBe(true);
|
||||
});
|
||||
|
||||
it('does not cache a stale active response when a newer task snapshot is queued', async () => {
|
||||
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
hoisted.getTeamTaskChangeSummaries
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
const snapshots: HookSnapshot[] = [];
|
||||
const onSnapshot = (snapshot: HookSnapshot): void => {
|
||||
snapshots.push(snapshot);
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
|
||||
onSnapshot,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(
|
||||
response({
|
||||
...changeSet(),
|
||||
files: [fileChange({ filePath: '/repo/src/stale.ts', relativePath: 'src/stale.ts' })],
|
||||
totalFiles: 1,
|
||||
totalLinesAdded: 1,
|
||||
})
|
||||
);
|
||||
await first.promise;
|
||||
});
|
||||
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
||||
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
||||
expect(snapshots.at(-1)?.summariesByTaskId).toEqual({});
|
||||
|
||||
await act(async () => {
|
||||
second.resolve(response());
|
||||
await second.promise;
|
||||
});
|
||||
|
||||
expect(hoisted.recordTaskChangePresence).toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'task-1',
|
||||
expect.any(Object),
|
||||
'no_changes'
|
||||
);
|
||||
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'task-1',
|
||||
'no_changes'
|
||||
);
|
||||
});
|
||||
|
||||
it('retries the initial load after React StrictMode effect remount replay', async () => {
|
||||
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
hoisted.getTeamTaskChangeSummaries
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
const snapshots: HookSnapshot[] = [];
|
||||
const onSnapshot = (snapshot: HookSnapshot): void => {
|
||||
snapshots.push(snapshot);
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
React.StrictMode,
|
||||
null,
|
||||
React.createElement(HookHarness, { tasks: [task()], onSnapshot })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(response());
|
||||
await first.promise;
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
second.resolve(response());
|
||||
await second.promise;
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.loading).toBe(false);
|
||||
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet?.taskId).toBe('task-1');
|
||||
});
|
||||
|
||||
it('clears initial loading and reports an error when the batch request times out', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
hoisted.getTeamTaskChangeSummaries.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
const snapshots: HookSnapshot[] = [];
|
||||
const onSnapshot = (snapshot: HookSnapshot): void => {
|
||||
snapshots.push(snapshot);
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(TEAM_CHANGES_LOAD_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.loading).toBe(false);
|
||||
expect(snapshots.at(-1)?.refreshing).toBe(false);
|
||||
expect(snapshots.at(-1)?.error).toBe('Team changes request timed out. Refresh to try again.');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not immediately run a queued refresh after a request failure', async () => {
|
||||
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
hoisted.getTeamTaskChangeSummaries.mockReturnValue(first.promise);
|
||||
|
||||
const snapshots: HookSnapshot[] = [];
|
||||
const onSnapshot = (snapshot: HookSnapshot): void => {
|
||||
snapshots.push(snapshot);
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
|
||||
onSnapshot,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
first.reject(new Error('boom'));
|
||||
await first.promise.catch(() => undefined);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
|
||||
expect(snapshots.at(-1)?.loading).toBe(false);
|
||||
expect(snapshots.at(-1)?.refreshing).toBe(false);
|
||||
expect(snapshots.at(-1)?.error).toBe('boom');
|
||||
});
|
||||
|
||||
it('clears loading and reports an error for a malformed batch response', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(malformedResponse());
|
||||
|
||||
const snapshots: HookSnapshot[] = [];
|
||||
const onSnapshot = (snapshot: HookSnapshot): void => {
|
||||
snapshots.push(snapshot);
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.loading).toBe(false);
|
||||
expect(snapshots.at(-1)?.refreshing).toBe(false);
|
||||
expect(snapshots.at(-1)?.error).toBe('Team changes response was malformed.');
|
||||
expect(snapshots.at(-1)?.summariesByTaskId).toEqual({});
|
||||
});
|
||||
|
||||
it('normalizes malformed batch response items before storing summaries', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(malformedItemResponse());
|
||||
|
||||
const snapshots: HookSnapshot[] = [];
|
||||
const onSnapshot = (snapshot: HookSnapshot): void => {
|
||||
snapshots.push(snapshot);
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.error).toBeNull();
|
||||
expect(snapshots.at(-1)?.summariesByTaskId['task-1']).toEqual({
|
||||
taskId: 'task-1',
|
||||
changeSet: null,
|
||||
});
|
||||
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not cache presence for incomplete change summaries', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(incompleteChangeSetResponse());
|
||||
|
||||
const snapshots: HookSnapshot[] = [];
|
||||
const onSnapshot = (snapshot: HookSnapshot): void => {
|
||||
snapshots.push(snapshot);
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.loading).toBe(false);
|
||||
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet).not.toBeNull();
|
||||
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
||||
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('caches has_changes for low-confidence summaries with safe file details', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
|
||||
|
||||
const snapshots: HookSnapshot[] = [];
|
||||
const onSnapshot = (snapshot: HookSnapshot): void => {
|
||||
snapshots.push(snapshot);
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet?.confidence).toBe('low');
|
||||
expect(hoisted.recordTaskChangePresence).toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'task-1',
|
||||
expect.any(Object),
|
||||
'has_changes'
|
||||
);
|
||||
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'task-1',
|
||||
'has_changes'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cache presence for summaries with unsafe file details', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(invalidFileSummaryResponse());
|
||||
|
||||
const snapshots: HookSnapshot[] = [];
|
||||
const onSnapshot = (snapshot: HookSnapshot): void => {
|
||||
snapshots.push(snapshot);
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet).not.toBeNull();
|
||||
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
||||
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders legacy malformed summaries without crashing the section', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(malformedLegacyChangeSet()));
|
||||
|
||||
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'scrollIntoView'
|
||||
);
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
try {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [task()],
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Expand section"]'
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
expandButton?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('legacy warning');
|
||||
expect(container.textContent).toContain(
|
||||
'The change summary reported one file without safe review details.'
|
||||
);
|
||||
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (scrollIntoViewDescriptor) {
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
||||
} else {
|
||||
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -64,7 +64,10 @@ import {
|
|||
parseStandaloneSlashCommand,
|
||||
} from '@shared/utils/slashCommands';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||
import {
|
||||
isMemberWorkSyncNudgeMessage,
|
||||
isTaskStallRemediationMessage,
|
||||
} from '@shared/utils/teamAutomationMessages';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
|
|
@ -403,7 +406,10 @@ const TaskStallRemediationRow = ({
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5" style={{ opacity: 0.82 }}>
|
||||
<span className="bg-amber-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
|
||||
style={{ backgroundColor: 'rgba(245, 158, 11, 0.12)' }}
|
||||
>
|
||||
automation
|
||||
</span>
|
||||
<span className="text-[11px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||
|
|
@ -442,6 +448,80 @@ const TaskStallRemediationRow = ({
|
|||
);
|
||||
};
|
||||
|
||||
const MemberWorkSyncNudgeRow = ({
|
||||
teamName,
|
||||
recipientName,
|
||||
recipientColor,
|
||||
taskRefs,
|
||||
intent,
|
||||
timestamp,
|
||||
onMemberNameClick,
|
||||
onTaskIdClick,
|
||||
}: {
|
||||
teamName: string;
|
||||
recipientName: string;
|
||||
recipientColor?: string;
|
||||
taskRefs?: InboxMessage['taskRefs'];
|
||||
intent?: InboxMessage['workSyncIntent'];
|
||||
timestamp: string;
|
||||
onMemberNameClick?: (memberName: string) => void;
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
}): React.JSX.Element => {
|
||||
const primaryTaskRef = taskRefs?.[0];
|
||||
const taskLabel = primaryTaskRef
|
||||
? formatTaskDisplayLabel({ id: primaryTaskRef.taskId, displayId: primaryTaskRef.displayId })
|
||||
: null;
|
||||
const extraTaskCount = Math.max((taskRefs?.length ?? 0) - 1, 0);
|
||||
const body =
|
||||
intent === 'review_pickup'
|
||||
? 'Asked teammate to pick up review'
|
||||
: 'Asked teammate to sync current work';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5" style={{ opacity: 0.82 }}>
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
|
||||
style={{ backgroundColor: 'rgba(245, 158, 11, 0.12)' }}
|
||||
>
|
||||
automation
|
||||
</span>
|
||||
<span className="text-[11px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||
work sync
|
||||
</span>
|
||||
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
||||
<MemberBadge
|
||||
name={recipientName}
|
||||
color={recipientColor}
|
||||
teamName={teamName}
|
||||
hideAvatar
|
||||
onClick={onMemberNameClick}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
{body}
|
||||
{primaryTaskRef && taskLabel ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-blue-300 hover:text-blue-200"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onTaskIdClick?.(primaryTaskRef.taskId);
|
||||
}}
|
||||
>
|
||||
{taskLabel}
|
||||
</button>
|
||||
{extraTaskCount > 0 ? ` +${extraTaskCount} more` : null}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{timestamp}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BootstrapSystemRow = ({
|
||||
teamName,
|
||||
eventKind,
|
||||
|
|
@ -1003,6 +1083,21 @@ export const ActivityItem = memo(
|
|||
);
|
||||
}
|
||||
|
||||
if (isMemberWorkSyncNudgeMessage(message)) {
|
||||
return (
|
||||
<MemberWorkSyncNudgeRow
|
||||
teamName={teamName}
|
||||
recipientName={message.to ?? 'teammate'}
|
||||
recipientColor={recipientColor}
|
||||
taskRefs={message.taskRefs}
|
||||
intent={message.workSyncIntent}
|
||||
timestamp={timestamp}
|
||||
onMemberNameClick={onMemberNameClick}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (bootstrapDisplay) {
|
||||
return (
|
||||
<BootstrapSystemRow
|
||||
|
|
|
|||
|
|
@ -1926,9 +1926,10 @@ export const CreateTeamDialog = ({
|
|||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
Only the team lead (main process) will be started — no teammates will
|
||||
be spawned. Works like a regular Claude session but with access to the task
|
||||
board for planning. Saves tokens by avoiding teammate coordination overhead.
|
||||
You can add members later from the team settings.
|
||||
be spawned. Works like a regular agent session in your chosen runtime
|
||||
(Claude Code, Codex, OpenCode, Gemini) but with access to the task board for
|
||||
planning. Saves tokens by avoiding teammate coordination overhead. You can
|
||||
add members later from the team settings.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,48 @@ interface OptionalSettingsSectionProps {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SUMMARY_PREFIXES_TO_STRIP = ['Provider:', 'Model:', 'Effort:', 'Worktree:'];
|
||||
|
||||
const MODEL_LABEL_OVERRIDES: Array<[RegExp, string]> = [
|
||||
[/claude[-\s]?opus[-\s]?4[-\s]?6/i, 'Opus 4.6'],
|
||||
[/claude[-\s]?opus[-\s]?4[-\s]?7/i, 'Opus 4.7'],
|
||||
[/claude[-\s]?opus[-\s]?4[-\s]?5/i, 'Opus 4.5'],
|
||||
[/claude[-\s]?sonnet[-\s]?4[-\s]?6/i, 'Sonnet 4.6'],
|
||||
[/claude[-\s]?sonnet[-\s]?4[-\s]?5/i, 'Sonnet 4.5'],
|
||||
[/claude[-\s]?haiku[-\s]?4[-\s]?5/i, 'Haiku 4.5'],
|
||||
];
|
||||
|
||||
const SUMMARY_CHIP_REWRITES: Array<[RegExp, string]> = [
|
||||
[/^Auto-approve tools$/i, 'Tools auto'],
|
||||
[/^Anthropic limited to 200K context$/i, '200K limit'],
|
||||
];
|
||||
|
||||
const toCompactChip = (value: string): string => {
|
||||
let chip = value.trim();
|
||||
for (const prefix of SUMMARY_PREFIXES_TO_STRIP) {
|
||||
if (chip.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
chip = chip.slice(prefix.length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [pattern, label] of MODEL_LABEL_OVERRIDES) {
|
||||
if (pattern.test(chip)) {
|
||||
chip = label;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [pattern, label] of SUMMARY_CHIP_REWRITES) {
|
||||
if (pattern.test(chip)) {
|
||||
chip = label;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (chip.length > 28) {
|
||||
chip = `${chip.slice(0, 27)}…`;
|
||||
}
|
||||
return chip;
|
||||
};
|
||||
|
||||
export const OptionalSettingsSection = ({
|
||||
title,
|
||||
description,
|
||||
|
|
@ -24,14 +66,25 @@ export const OptionalSettingsSection = ({
|
|||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
const visibleSummary = useMemo(
|
||||
() =>
|
||||
summary
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4),
|
||||
[summary]
|
||||
);
|
||||
const chips = useMemo(() => {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of summary) {
|
||||
const chip = toCompactChip(raw);
|
||||
if (!chip) continue;
|
||||
const key = chip.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(chip);
|
||||
if (result.length >= 3) break;
|
||||
}
|
||||
return result;
|
||||
}, [summary]);
|
||||
|
||||
const overflowCount = useMemo(() => {
|
||||
const total = summary.map((value) => value.trim()).filter(Boolean).length;
|
||||
return Math.max(0, total - chips.length);
|
||||
}, [summary, chips.length]);
|
||||
|
||||
const containerBackground = isLight
|
||||
? 'color-mix(in srgb, var(--color-surface-overlay) 30%, white 70%)'
|
||||
|
|
@ -65,46 +118,64 @@ export const OptionalSettingsSection = ({
|
|||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-start justify-between gap-3 p-3 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
className="flex w-full items-center gap-3 p-2.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<div
|
||||
className="mt-0.5 rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] p-1.5"
|
||||
style={{ color: headerIconColor }}
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium" style={{ color: headerTitleColor }}>
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
className="rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-2 py-0.5 text-[10px] uppercase tracking-wide"
|
||||
style={{ color: headerMutedColor }}
|
||||
>
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs" style={{ color: headerMutedColor }}>
|
||||
{description}
|
||||
</p>
|
||||
{!isOpen ? (
|
||||
<p className="mt-1.5 line-clamp-2 text-[11px]" style={{ color: headerMutedColor }}>
|
||||
{visibleSummary.length > 0
|
||||
? visibleSummary.join(' • ')
|
||||
: 'Collapsed by default to keep the primary flow focused.'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)]"
|
||||
style={{ color: headerIconColor }}
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium" style={{ color: headerTitleColor }}>
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{ color: headerMutedColor }}
|
||||
>
|
||||
Optional
|
||||
</span>
|
||||
|
||||
{!isOpen && chips.length > 0 ? (
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden"
|
||||
style={{ color: headerMutedColor }}
|
||||
>
|
||||
<span aria-hidden="true" className="select-none text-[11px] opacity-50">
|
||||
•
|
||||
</span>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{chips.map((chip, index) => (
|
||||
<React.Fragment key={`${chip}-${index}`}>
|
||||
{index > 0 ? (
|
||||
<span aria-hidden="true" className="select-none text-[11px] opacity-50">
|
||||
•
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[11px]">
|
||||
{chip}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{overflowCount > 0 ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="select-none text-[11px] opacity-50">
|
||||
•
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px]">+{overflowCount}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'mt-0.5 size-4 shrink-0 transition-transform duration-150',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
className={cn('size-4 shrink-0 transition-transform duration-150', isOpen && 'rotate-90')}
|
||||
style={{ color: headerIconColor }}
|
||||
/>
|
||||
</button>
|
||||
|
|
@ -116,6 +187,11 @@ export const OptionalSettingsSection = ({
|
|||
backgroundColor: contentBackground,
|
||||
}}
|
||||
>
|
||||
{description ? (
|
||||
<p className="mb-3 text-xs" style={{ color: headerMutedColor }}>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
|
@ -365,26 +366,6 @@ export const MembersEditorSection = ({
|
|||
{headerExtra}
|
||||
{!hideContent && (
|
||||
<>
|
||||
{showWorktreeIsolationControls ? (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-2"
|
||||
title={worktreeIsolationDisabledReason ?? undefined}
|
||||
>
|
||||
<Checkbox
|
||||
id={worktreeDefaultControlId}
|
||||
checked={teammateWorktreeDefault}
|
||||
disabled={Boolean(worktreeIsolationDisabledReason && !teammateWorktreeDefault)}
|
||||
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={worktreeDefaultControlId}
|
||||
className="flex min-w-0 cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<GitBranch className="size-3.5 shrink-0" />
|
||||
<span className="truncate">Run teammates in separate worktrees</span>
|
||||
</Label>
|
||||
</div>
|
||||
) : null}
|
||||
{disableAddMember && addMemberLockReason ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
|
||||
) : null}
|
||||
|
|
@ -396,98 +377,126 @@ export const MembersEditorSection = ({
|
|||
onClose={toggleJsonEditor}
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{activeMembers.map((member, index) => (
|
||||
<MemberDraftRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
index={index}
|
||||
avatarSrc={getParticipantAvatarUrlByIndex(index + 1)}
|
||||
resolvedColor={memberColorMap.get(member.id)}
|
||||
nameError={validateMemberName?.(member.name) ?? null}
|
||||
onNameChange={updateMemberName}
|
||||
onRoleChange={updateMemberRole}
|
||||
onCustomRoleChange={updateMemberCustomRole}
|
||||
onRemove={removeMember}
|
||||
showWorkflow={showWorkflow}
|
||||
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
|
||||
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
|
||||
onProviderChange={updateMemberProvider}
|
||||
onModelChange={updateMemberModel}
|
||||
onEffortChange={updateMemberEffort}
|
||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
|
||||
onWorktreeIsolationChange={updateMemberIsolation}
|
||||
inheritedProviderId={inheritedProviderId}
|
||||
inheritedModel={inheritedModel}
|
||||
inheritedEffort={inheritedEffort}
|
||||
limitContext={limitContext}
|
||||
forceInheritedModelSettings={forceInheritedModelSettings}
|
||||
draftKeyPrefix={draftKeyPrefix}
|
||||
projectPath={projectPath}
|
||||
mentionSuggestions={mentionSuggestions}
|
||||
taskSuggestions={taskSuggestions}
|
||||
teamSuggestions={teamSuggestions}
|
||||
lockProviderModel={lockProviderModel}
|
||||
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
|
||||
identityLockReason={identityLockReason}
|
||||
modelLockReason={modelLockReason}
|
||||
warningText={memberWarningById?.[member.id] ?? null}
|
||||
infoText={memberInfoById?.[member.id] ?? null}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={memberModelIssueById?.[member.id] ?? null}
|
||||
modelIssueReasonByProvider={modelIssueReasonByProvider}
|
||||
modelUnavailableReasonByProvider={modelUnavailableReasonByProvider}
|
||||
/>
|
||||
))}
|
||||
{softDeleteMembers && removedMembers.length > 0 ? (
|
||||
<div className="pt-2">
|
||||
<div className="mb-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
Removed ({removedMembers.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{removedMembers.map((member, index) => (
|
||||
<MemberDraftRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
index={activeMembers.length + index}
|
||||
avatarSrc={getParticipantAvatarUrlByIndex(activeMembers.length + index + 1)}
|
||||
resolvedColor={memberColorMap.get(member.id)}
|
||||
nameError={null}
|
||||
onNameChange={updateMemberName}
|
||||
onRoleChange={updateMemberRole}
|
||||
onCustomRoleChange={updateMemberCustomRole}
|
||||
onRemove={removeMember}
|
||||
onRestore={restoreMember}
|
||||
showWorkflow={showWorkflow}
|
||||
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
|
||||
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
|
||||
onProviderChange={updateMemberProvider}
|
||||
onModelChange={updateMemberModel}
|
||||
onEffortChange={updateMemberEffort}
|
||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||
onWorktreeIsolationChange={updateMemberIsolation}
|
||||
inheritedProviderId={inheritedProviderId}
|
||||
inheritedModel={inheritedModel}
|
||||
inheritedEffort={inheritedEffort}
|
||||
limitContext={limitContext}
|
||||
forceInheritedModelSettings={forceInheritedModelSettings}
|
||||
draftKeyPrefix={draftKeyPrefix}
|
||||
projectPath={projectPath}
|
||||
mentionSuggestions={mentionSuggestions}
|
||||
taskSuggestions={taskSuggestions}
|
||||
teamSuggestions={teamSuggestions}
|
||||
lockProviderModel
|
||||
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
|
||||
isRemoved
|
||||
warningText={null}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
showWorktreeIsolationControls
|
||||
? 'overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showWorktreeIsolationControls ? (
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-[var(--color-border)] px-2.5 py-2"
|
||||
title={worktreeIsolationDisabledReason ?? undefined}
|
||||
>
|
||||
<Checkbox
|
||||
id={worktreeDefaultControlId}
|
||||
checked={teammateWorktreeDefault}
|
||||
disabled={Boolean(worktreeIsolationDisabledReason && !teammateWorktreeDefault)}
|
||||
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={worktreeDefaultControlId}
|
||||
className="flex min-w-0 cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<GitBranch className="size-3.5 shrink-0" />
|
||||
<span className="truncate">Run teammates in separate worktrees</span>
|
||||
</Label>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn('space-y-2', showWorktreeIsolationControls && 'p-2')}>
|
||||
{activeMembers.map((member, index) => (
|
||||
<MemberDraftRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
index={index}
|
||||
avatarSrc={getParticipantAvatarUrlByIndex(index + 1)}
|
||||
resolvedColor={memberColorMap.get(member.id)}
|
||||
nameError={validateMemberName?.(member.name) ?? null}
|
||||
onNameChange={updateMemberName}
|
||||
onRoleChange={updateMemberRole}
|
||||
onCustomRoleChange={updateMemberCustomRole}
|
||||
onRemove={removeMember}
|
||||
showWorkflow={showWorkflow}
|
||||
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
|
||||
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
|
||||
onProviderChange={updateMemberProvider}
|
||||
onModelChange={updateMemberModel}
|
||||
onEffortChange={updateMemberEffort}
|
||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
|
||||
onWorktreeIsolationChange={updateMemberIsolation}
|
||||
inheritedProviderId={inheritedProviderId}
|
||||
inheritedModel={inheritedModel}
|
||||
inheritedEffort={inheritedEffort}
|
||||
limitContext={limitContext}
|
||||
forceInheritedModelSettings={forceInheritedModelSettings}
|
||||
draftKeyPrefix={draftKeyPrefix}
|
||||
projectPath={projectPath}
|
||||
mentionSuggestions={mentionSuggestions}
|
||||
taskSuggestions={taskSuggestions}
|
||||
teamSuggestions={teamSuggestions}
|
||||
lockProviderModel={lockProviderModel}
|
||||
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
|
||||
identityLockReason={identityLockReason}
|
||||
modelLockReason={modelLockReason}
|
||||
warningText={memberWarningById?.[member.id] ?? null}
|
||||
infoText={memberInfoById?.[member.id] ?? null}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={memberModelIssueById?.[member.id] ?? null}
|
||||
modelIssueReasonByProvider={modelIssueReasonByProvider}
|
||||
modelUnavailableReasonByProvider={modelUnavailableReasonByProvider}
|
||||
/>
|
||||
))}
|
||||
{softDeleteMembers && removedMembers.length > 0 ? (
|
||||
<div className="pt-2">
|
||||
<div className="mb-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
Removed ({removedMembers.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{removedMembers.map((member, index) => (
|
||||
<MemberDraftRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
index={activeMembers.length + index}
|
||||
avatarSrc={getParticipantAvatarUrlByIndex(activeMembers.length + index + 1)}
|
||||
resolvedColor={memberColorMap.get(member.id)}
|
||||
nameError={null}
|
||||
onNameChange={updateMemberName}
|
||||
onRoleChange={updateMemberRole}
|
||||
onCustomRoleChange={updateMemberCustomRole}
|
||||
onRemove={removeMember}
|
||||
onRestore={restoreMember}
|
||||
showWorkflow={showWorkflow}
|
||||
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
|
||||
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
|
||||
onProviderChange={updateMemberProvider}
|
||||
onModelChange={updateMemberModel}
|
||||
onEffortChange={updateMemberEffort}
|
||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||
onWorktreeIsolationChange={updateMemberIsolation}
|
||||
inheritedProviderId={inheritedProviderId}
|
||||
inheritedModel={inheritedModel}
|
||||
inheritedEffort={inheritedEffort}
|
||||
limitContext={limitContext}
|
||||
forceInheritedModelSettings={forceInheritedModelSettings}
|
||||
draftKeyPrefix={draftKeyPrefix}
|
||||
projectPath={projectPath}
|
||||
mentionSuggestions={mentionSuggestions}
|
||||
taskSuggestions={taskSuggestions}
|
||||
teamSuggestions={teamSuggestions}
|
||||
lockProviderModel
|
||||
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
|
||||
isRemoved
|
||||
warningText={null}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{hasDuplicates ? (
|
||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
|||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
isMemberWorkSyncNudgeMessage,
|
||||
isReviewPickupEscalationMessage,
|
||||
isTaskStallRemediationMessage,
|
||||
} from '@shared/utils/teamAutomationMessages';
|
||||
|
|
@ -606,6 +607,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
(m) =>
|
||||
m.messageKind !== 'task_comment_notification' &&
|
||||
!isTaskStallRemediationMessage(m) &&
|
||||
!isMemberWorkSyncNudgeMessage(m) &&
|
||||
!isReviewPickupEscalationMessage(m) &&
|
||||
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const TEAM_CHANGES_LOAD_TIMEOUT_MS = 45_000;
|
||||
// Main-process team summary batches have a 30s deadline; keep the renderer guard above it.
|
||||
export const TEAM_CHANGES_LOAD_TIMEOUT_MS = 35_000;
|
||||
|
||||
export function withTeamChangesLoadTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
|
|
|
|||
|
|
@ -10,9 +10,15 @@ import {
|
|||
buildTeamChangesTasksFingerprint,
|
||||
} from './teamChangesRequestPlan';
|
||||
|
||||
import type { TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types';
|
||||
import type {
|
||||
TaskChangePresenceState,
|
||||
TaskChangeSetV2,
|
||||
TeamTaskChangeSummaryItem,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
|
||||
const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000;
|
||||
|
||||
export interface TeamChangeSummaryState {
|
||||
taskId: string;
|
||||
|
|
@ -47,6 +53,89 @@ interface UseTeamChangesSummariesResult {
|
|||
refresh: () => void;
|
||||
}
|
||||
|
||||
function normalizeTeamChangeSummaryItem(item: unknown): TeamTaskChangeSummaryItem | null {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = item as Partial<TeamTaskChangeSummaryItem>;
|
||||
const taskId = typeof candidate.taskId === 'string' ? candidate.taskId.trim() : '';
|
||||
if (!taskId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const changeSet =
|
||||
candidate.changeSet &&
|
||||
typeof candidate.changeSet === 'object' &&
|
||||
!Array.isArray(candidate.changeSet)
|
||||
? candidate.changeSet
|
||||
: null;
|
||||
const error = typeof candidate.error === 'string' ? candidate.error : undefined;
|
||||
return {
|
||||
taskId,
|
||||
changeSet,
|
||||
...(error ? { error } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getSafeResponseItems(response: unknown): TeamTaskChangeSummaryItem[] {
|
||||
if (
|
||||
!response ||
|
||||
typeof response !== 'object' ||
|
||||
!Array.isArray((response as { items?: unknown }).items)
|
||||
) {
|
||||
throw new Error('Team changes response was malformed.');
|
||||
}
|
||||
return (response as { items: unknown[] }).items
|
||||
.map(normalizeTeamChangeSummaryItem)
|
||||
.filter((item): item is TeamTaskChangeSummaryItem => item !== null);
|
||||
}
|
||||
|
||||
function hasSafeFileSummaries(changeSet: TaskChangeSetV2): boolean {
|
||||
return changeSet.files.every(
|
||||
(file) =>
|
||||
file &&
|
||||
typeof file === 'object' &&
|
||||
typeof file.filePath === 'string' &&
|
||||
file.filePath.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function isMinimalPresenceChangeSet(changeSet: TaskChangeSetV2): boolean {
|
||||
return Boolean(
|
||||
Array.isArray(changeSet.files) &&
|
||||
hasSafeFileSummaries(changeSet) &&
|
||||
Array.isArray(changeSet.warnings) &&
|
||||
Number.isFinite(changeSet.totalFiles) &&
|
||||
Number(changeSet.totalFiles) >= 0 &&
|
||||
typeof changeSet.computedAt === 'string' &&
|
||||
changeSet.computedAt.trim().length > 0 &&
|
||||
changeSet.scope &&
|
||||
typeof changeSet.scope === 'object' &&
|
||||
!Array.isArray(changeSet.scope)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCacheablePresenceFromChangeSet(
|
||||
changeSet: TaskChangeSetV2
|
||||
): Exclude<TaskChangePresenceState, 'unknown'> | null {
|
||||
if (!isMinimalPresenceChangeSet(changeSet)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextPresence = resolveTaskChangePresenceFromResult(changeSet);
|
||||
if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') {
|
||||
return nextPresence;
|
||||
}
|
||||
if (
|
||||
nextPresence === 'no_changes' &&
|
||||
(changeSet.confidence === 'high' || changeSet.confidence === 'medium')
|
||||
) {
|
||||
return nextPresence;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useTeamChangesSummaries({
|
||||
teamName,
|
||||
tasks,
|
||||
|
|
@ -71,6 +160,7 @@ export function useTeamChangesSummaries({
|
|||
const requestSeqRef = useRef(0);
|
||||
const activeRequestSeqRef = useRef<number | null>(null);
|
||||
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
|
||||
const autoRefreshBlockedUntilRef = useRef(0);
|
||||
const sectionOpenRef = useRef(sectionOpen);
|
||||
const unknownScanCursorRef = useRef(0);
|
||||
const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
|
||||
|
|
@ -81,11 +171,16 @@ export function useTeamChangesSummaries({
|
|||
sectionOpenRef.current = sectionOpen;
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
requestSeqRef.current += 1;
|
||||
activeRequestSeqRef.current = null;
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
hasLoadedRef.current = false;
|
||||
unknownScanCursorRef.current = 0;
|
||||
lastRequestedTasksFingerprintRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -95,6 +190,12 @@ export function useTeamChangesSummaries({
|
|||
showSpinner = false,
|
||||
preserveOnError = true,
|
||||
}: TeamChangesLoadOptions = {}): Promise<void> => {
|
||||
if (forceFresh) {
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
} else if (autoRefreshBlockedUntilRef.current > Date.now()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
|
||||
const previous = queuedRefreshOptionsRef.current;
|
||||
queuedRefreshOptionsRef.current = {
|
||||
|
|
@ -104,7 +205,6 @@ export function useTeamChangesSummaries({
|
|||
? Boolean(previous.preserveOnError && preserveOnError)
|
||||
: preserveOnError,
|
||||
};
|
||||
requestSeqRef.current += 1;
|
||||
if (activeRequestSeqRef.current === null && sectionOpenRef.current) {
|
||||
setQueuedRefreshTick((value) => value + 1);
|
||||
}
|
||||
|
|
@ -124,6 +224,7 @@ export function useTeamChangesSummaries({
|
|||
|
||||
if (plan.requests.length === 0) {
|
||||
setSummariesByTaskId({});
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
return;
|
||||
|
|
@ -143,16 +244,22 @@ export function useTeamChangesSummaries({
|
|||
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
if (queuedRefreshOptionsRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
const responseItems = getSafeResponseItems(response);
|
||||
|
||||
const currentTaskIds = new Set(tasks.map((task) => task.id));
|
||||
for (const item of response.items) {
|
||||
for (const item of responseItems) {
|
||||
const changeSet = item.changeSet;
|
||||
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
||||
if (!changeSet || !options) continue;
|
||||
|
||||
const nextPresence = resolveTaskChangePresenceFromResult(changeSet);
|
||||
const nextPresence = resolveCacheablePresenceFromChangeSet(changeSet);
|
||||
if (!nextPresence) continue;
|
||||
recordTaskChangePresence(teamName, item.taskId, options, nextPresence);
|
||||
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence ?? 'unknown');
|
||||
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence);
|
||||
}
|
||||
|
||||
setSummariesByTaskId((previous) => {
|
||||
|
|
@ -162,7 +269,7 @@ export function useTeamChangesSummaries({
|
|||
next[taskId] = summary;
|
||||
}
|
||||
}
|
||||
for (const item of response.items) {
|
||||
for (const item of responseItems) {
|
||||
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
||||
if (!options) continue;
|
||||
next[item.taskId] = {
|
||||
|
|
@ -177,6 +284,8 @@ export function useTeamChangesSummaries({
|
|||
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = Date.now() + TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS;
|
||||
if (!preserveOnError) {
|
||||
setSummariesByTaskId({});
|
||||
}
|
||||
|
|
@ -208,6 +317,7 @@ export function useTeamChangesSummaries({
|
|||
requestSeqRef.current += 1;
|
||||
activeRequestSeqRef.current = null;
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
unknownScanCursorRef.current = 0;
|
||||
lastRequestedTasksFingerprintRef.current = null;
|
||||
setSummariesByTaskId({});
|
||||
|
|
@ -220,6 +330,7 @@ export function useTeamChangesSummaries({
|
|||
requestSeqRef.current += 1;
|
||||
activeRequestSeqRef.current = null;
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
hasLoadedRef.current = false;
|
||||
lastRequestedTasksFingerprintRef.current = null;
|
||||
setSummariesByTaskId({});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
|||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayoutMode';
|
||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
|
|
@ -1866,6 +1869,8 @@ const resolvedMembersSelectorCache = new Map<
|
|||
string,
|
||||
{
|
||||
snapshotRef: TeamViewSnapshot['members'];
|
||||
configMembersRef: TeamViewSnapshot['config']['members'] | undefined;
|
||||
tasksRef: TeamViewSnapshot['tasks'] | undefined;
|
||||
metaMembersRef: TeamMemberActivityMeta['members'] | undefined;
|
||||
result: ResolvedTeamMember[];
|
||||
}
|
||||
|
|
@ -1925,6 +1930,64 @@ function buildResolvedMembers(
|
|||
return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name]));
|
||||
}
|
||||
|
||||
function isDisplayableFallbackCurrentTask(task: TeamViewSnapshot['tasks'][number]): boolean {
|
||||
return (
|
||||
task.status === 'in_progress' &&
|
||||
getTeamTaskWorkflowColumn(task) !== 'review' &&
|
||||
!isTeamTaskFinalForCompletionNotification(task)
|
||||
);
|
||||
}
|
||||
|
||||
function buildConfigFallbackMemberSnapshots(snapshot: TeamViewSnapshot): TeamMemberSnapshot[] {
|
||||
const configMembers = snapshot.config.members ?? [];
|
||||
const hasConfiguredTeammate = configMembers.some((member) => {
|
||||
const name = member.name?.trim();
|
||||
return Boolean(name) && !member.removedAt && !isLeadMember(member);
|
||||
});
|
||||
if (!hasConfiguredTeammate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const fallbackMembers: TeamMemberSnapshot[] = [];
|
||||
for (const member of configMembers) {
|
||||
const name = member.name?.trim();
|
||||
if (!name) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (seenNames.has(key)) continue;
|
||||
seenNames.add(key);
|
||||
|
||||
const ownedTasks = snapshot.tasks.filter((task) => task.owner === name);
|
||||
const currentTask = ownedTasks.find(isDisplayableFallbackCurrentTask);
|
||||
fallbackMembers.push({
|
||||
name,
|
||||
agentId: member.agentId,
|
||||
currentTaskId: currentTask?.id ?? null,
|
||||
taskCount: ownedTasks.length,
|
||||
color: member.color ?? getMemberColorByName(name),
|
||||
agentType: member.agentType,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
isolation: member.isolation,
|
||||
providerId: member.providerId,
|
||||
providerBackendId: member.providerBackendId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
selectedFastMode: member.fastMode,
|
||||
cwd: member.cwd,
|
||||
removedAt: member.removedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return fallbackMembers;
|
||||
}
|
||||
|
||||
function getResolvableMemberSnapshots(snapshot: TeamViewSnapshot): readonly TeamMemberSnapshot[] {
|
||||
return snapshot.members.length > 0
|
||||
? snapshot.members
|
||||
: buildConfigFallbackMemberSnapshots(snapshot);
|
||||
}
|
||||
|
||||
function buildResolvedMember(
|
||||
snapshot: TeamMemberSnapshot,
|
||||
activity: MemberActivityMetaEntry | undefined
|
||||
|
|
@ -2038,14 +2101,24 @@ export function selectResolvedMembersForTeamName(
|
|||
|
||||
const meta = state.memberActivityMetaByTeam[teamName];
|
||||
const metaMembers = meta?.members;
|
||||
const shouldUseConfigFallback = snapshot.members.length === 0;
|
||||
const configMembersRef = shouldUseConfigFallback ? snapshot.config.members : undefined;
|
||||
const tasksRef = shouldUseConfigFallback ? snapshot.tasks : undefined;
|
||||
const cached = resolvedMembersSelectorCache.get(teamName);
|
||||
if (cached?.snapshotRef === snapshot.members && cached.metaMembersRef === metaMembers) {
|
||||
if (
|
||||
cached?.snapshotRef === snapshot.members &&
|
||||
cached.configMembersRef === configMembersRef &&
|
||||
cached.tasksRef === tasksRef &&
|
||||
cached.metaMembersRef === metaMembers
|
||||
) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const result = buildResolvedMembers(snapshot.members, meta);
|
||||
const result = buildResolvedMembers(getResolvableMemberSnapshots(snapshot), meta);
|
||||
resolvedMembersSelectorCache.set(teamName, {
|
||||
snapshotRef: snapshot.members,
|
||||
configMembersRef,
|
||||
tasksRef,
|
||||
metaMembersRef: metaMembers,
|
||||
result,
|
||||
});
|
||||
|
|
@ -2065,7 +2138,9 @@ export function selectResolvedMemberForTeamName(
|
|||
return null;
|
||||
}
|
||||
|
||||
const snapshotMember = snapshot.members.find((member) => member.name === memberName);
|
||||
const snapshotMember = getResolvableMemberSnapshots(snapshot).find(
|
||||
(member) => member.name === memberName
|
||||
);
|
||||
if (!snapshotMember) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -3442,7 +3517,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
|
||||
setTeamGraphLayoutMode: (teamName, mode) => {
|
||||
set((state) => {
|
||||
if ((state.graphLayoutModeByTeam[teamName] ?? 'radial') === mode) {
|
||||
if ((state.graphLayoutModeByTeam[teamName] ?? DEFAULT_TEAM_GRAPH_LAYOUT_MODE) === mode) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -401,6 +401,8 @@ function formatRuntimeAdvisoryBaseLabel(
|
|||
return 'Codex native timeout';
|
||||
case 'network_error':
|
||||
return 'Network error';
|
||||
case 'filesystem_error':
|
||||
return 'Disk space error';
|
||||
case 'provider_overloaded':
|
||||
return providerLabel ? `${providerLabel} overload` : 'Provider overload';
|
||||
case 'protocol_proof_missing':
|
||||
|
|
@ -430,6 +432,8 @@ function formatRuntimeAdvisoryBaseLabel(
|
|||
return 'Codex native retry';
|
||||
case 'network_error':
|
||||
return 'Network retry';
|
||||
case 'filesystem_error':
|
||||
return 'Disk space retry';
|
||||
case 'provider_overloaded':
|
||||
return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry';
|
||||
case 'protocol_proof_missing':
|
||||
|
|
@ -471,6 +475,11 @@ function formatRuntimeAdvisoryTitle(
|
|||
);
|
||||
case 'network_error':
|
||||
return appendRuntimeAdvisoryRawMessage('Network or connectivity error.', advisory.message);
|
||||
case 'filesystem_error':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
'Local disk is full or unavailable.',
|
||||
advisory.message
|
||||
);
|
||||
case 'provider_overloaded':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
'Provider is temporarily overloaded.',
|
||||
|
|
@ -529,6 +538,11 @@ function formatRuntimeAdvisoryTitle(
|
|||
'Network or connectivity issue. SDK is retrying automatically.',
|
||||
advisory.message
|
||||
);
|
||||
case 'filesystem_error':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
'Local disk is full or unavailable. SDK is retrying automatically.',
|
||||
advisory.message
|
||||
);
|
||||
case 'provider_overloaded':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
'Provider is temporarily overloaded. SDK is retrying automatically.',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import {
|
||||
isMemberWorkSyncNudgeMessage,
|
||||
isReviewPickupEscalationMessage,
|
||||
isTaskStallRemediationMessage,
|
||||
} from '@shared/utils/teamAutomationMessages';
|
||||
|
|
@ -135,7 +136,8 @@ export function filterTeamMessages(
|
|||
let list = messages.filter(
|
||||
(m) =>
|
||||
m.messageKind !== 'task_comment_notification' &&
|
||||
(includeAutomationEvents || !isTaskStallRemediationMessage(m)) &&
|
||||
(includeAutomationEvents ||
|
||||
(!isTaskStallRemediationMessage(m) && !isMemberWorkSyncNudgeMessage(m))) &&
|
||||
!isReviewPickupEscalationMessage(m) &&
|
||||
!isTeamInternalControlMessageEnvelope(m)
|
||||
);
|
||||
|
|
|
|||
3
src/shared/constants/teamGraphLayoutMode.ts
Normal file
3
src/shared/constants/teamGraphLayoutMode.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { GraphLayoutMode } from '@claude-teams/agent-graph';
|
||||
|
||||
export const DEFAULT_TEAM_GRAPH_LAYOUT_MODE: GraphLayoutMode = 'grid-under-lead';
|
||||
|
|
@ -857,6 +857,7 @@ export interface MemberRuntimeAdvisory {
|
|||
| 'auth_error'
|
||||
| 'codex_native_timeout'
|
||||
| 'network_error'
|
||||
| 'filesystem_error'
|
||||
| 'provider_overloaded'
|
||||
| 'protocol_proof_missing'
|
||||
| 'backend_error'
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export function isTaskStallRemediationMessage(message: AutomationMessageLike): b
|
|||
);
|
||||
}
|
||||
|
||||
export function isMemberWorkSyncNudgeMessage(message: AutomationMessageLike): boolean {
|
||||
return message.messageKind === 'member_work_sync_nudge';
|
||||
}
|
||||
|
||||
export function isReviewPickupEscalationMessage(message: AutomationMessageLike): boolean {
|
||||
return (
|
||||
message.source === 'system_notification' &&
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ describe('stable slot layout', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('uses three grid columns for six owners in rows layout', () => {
|
||||
it('uses two grid columns for six owners in rows layout', () => {
|
||||
const { nodes, layout } = buildSixOwnerGraph();
|
||||
const snapshot = getSnapshot(nodes, {
|
||||
...layout,
|
||||
|
|
@ -202,8 +202,8 @@ describe('stable slot layout', () => {
|
|||
});
|
||||
|
||||
expect(snapshot.ownerSlotLayoutKind).toBe('grid-under-lead');
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 1, 1, 1]);
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]);
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 1, 1, 2, 2]);
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 0, 1, 0, 1]);
|
||||
});
|
||||
|
||||
it('packs eight radial owners into row-orbit rows without crossing the lead exclusion', () => {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
|
|||
findLogsForTask: vi.fn(),
|
||||
invalidateTeamConfig: vi.fn(),
|
||||
invalidateTeamMessageFeed: vi.fn(),
|
||||
invalidateMemberRuntimeAdvisory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -218,9 +219,10 @@ describe('ipc teams handlers', () => {
|
|||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||
updateConfig: vi.fn(async () => ({ name: 'My Team' })),
|
||||
sendMessage: vi.fn(
|
||||
async (_teamName: string, _request: unknown) => ({ deliveredToInbox: true, messageId: 'm1' })
|
||||
) as ReturnType<
|
||||
sendMessage: vi.fn(async (_teamName: string, _request: unknown) => ({
|
||||
deliveredToInbox: true,
|
||||
messageId: 'm1',
|
||||
})) as ReturnType<
|
||||
typeof vi.fn<
|
||||
(
|
||||
teamName: string,
|
||||
|
|
@ -251,6 +253,7 @@ describe('ipc teams handlers', () => {
|
|||
removeTaskRelationship: vi.fn(async () => undefined),
|
||||
replaceMembers: vi.fn(async () => undefined),
|
||||
invalidateMessageFeed: vi.fn(() => undefined),
|
||||
invalidateTeamRuntimeAdvisories: vi.fn(() => undefined),
|
||||
createTeamConfig: vi.fn(async () => undefined),
|
||||
getSavedRequest: vi.fn(async (): Promise<TeamCreateRequest | null> => null),
|
||||
};
|
||||
|
|
@ -284,9 +287,7 @@ describe('ipc teams handlers', () => {
|
|||
async (_teamName: string, _memberName: string): Promise<TeamProviderId | undefined> =>
|
||||
undefined
|
||||
) as ReturnType<
|
||||
typeof vi.fn<
|
||||
(teamName: string, memberName: string) => Promise<TeamProviderId | undefined>
|
||||
>
|
||||
typeof vi.fn<(teamName: string, memberName: string) => Promise<TeamProviderId | undefined>>
|
||||
>,
|
||||
isOpenCodeRuntimeRecipient: vi.fn(async () => false),
|
||||
relayOpenCodeMemberInboxMessages: vi.fn(async () => ({
|
||||
|
|
@ -369,6 +370,7 @@ describe('ipc teams handlers', () => {
|
|||
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||
mockTeamDataWorkerClient.invalidateTeamConfig.mockReset();
|
||||
mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset();
|
||||
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockReset();
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockReset();
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined);
|
||||
provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset();
|
||||
|
|
@ -700,30 +702,29 @@ describe('ipc teams handlers', () => {
|
|||
expect(request?.text).not.toContain('Reply using the SendMessage tool');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['anthropic' as const],
|
||||
['gemini' as const],
|
||||
[undefined],
|
||||
])('keeps SendMessage reply instructions for %s user direct messages', async (providerId) => {
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce(providerId);
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
it.each([['anthropic' as const], ['gemini' as const], [undefined]])(
|
||||
'keeps SendMessage reply instructions for %s user direct messages',
|
||||
async (providerId) => {
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce(providerId);
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
||||
const result = (await sendHandler!({} as never, 'my-team', {
|
||||
member: 'alice',
|
||||
text: 'Здесь?',
|
||||
})) as { success: boolean };
|
||||
const result = (await sendHandler!({} as never, 'my-team', {
|
||||
member: 'alice',
|
||||
text: 'Здесь?',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const request = service.sendMessage.mock.calls.at(-1)?.[1] as
|
||||
| { text?: string; messageId?: string }
|
||||
| undefined;
|
||||
expect(request).toBeDefined();
|
||||
expect(request).not.toHaveProperty('messageId');
|
||||
expect(request?.text).toContain('Reply using the SendMessage tool');
|
||||
expect(request?.text).toContain('to="user"');
|
||||
expect(request?.text).not.toContain('agent-teams_message_send');
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
const request = service.sendMessage.mock.calls.at(-1)?.[1] as
|
||||
| { text?: string; messageId?: string }
|
||||
| undefined;
|
||||
expect(request).toBeDefined();
|
||||
expect(request).not.toHaveProperty('messageId');
|
||||
expect(request?.text).toContain('Reply using the SendMessage tool');
|
||||
expect(request?.text).toContain('to="user"');
|
||||
expect(request?.text).not.toContain('agent-teams_message_send');
|
||||
}
|
||||
);
|
||||
|
||||
it('stores base text and returns runtimeDelivery success for OpenCode teammate sends', async () => {
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||
|
|
@ -1278,14 +1279,11 @@ describe('ipc teams handlers', () => {
|
|||
.mockResolvedValueOnce([{ teamName: 'background-fresh', displayName: 'Background Fresh' }])
|
||||
.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]);
|
||||
|
||||
const createResult = (await handlers.get(TEAM_CREATE)!(
|
||||
{ sender: { send: vi.fn() } } as never,
|
||||
{
|
||||
teamName: 'my-team',
|
||||
members: [{ name: 'alice' }],
|
||||
cwd: os.tmpdir(),
|
||||
}
|
||||
)) as { success: boolean };
|
||||
const createResult = (await handlers.get(TEAM_CREATE)!({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'my-team',
|
||||
members: [{ name: 'alice' }],
|
||||
cwd: os.tmpdir(),
|
||||
})) as { success: boolean };
|
||||
expect(createResult.success).toBe(false);
|
||||
vi.mocked(console.error).mockClear();
|
||||
|
||||
|
|
@ -2752,6 +2750,78 @@ describe('ipc teams handlers', () => {
|
|||
expect(service.permanentlyDeleteTeam).toHaveBeenCalledWith('my-team');
|
||||
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
||||
});
|
||||
|
||||
it('invalidates worker config cache after roster metadata mutations', async () => {
|
||||
const addHandler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
const removeHandler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||
const replaceHandler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
const updateRoleHandler = handlers.get(TEAM_UPDATE_MEMBER_ROLE)!;
|
||||
|
||||
let result = (await addHandler({} as never, 'my-team', {
|
||||
name: 'alice',
|
||||
role: 'developer',
|
||||
})) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.addMember).toHaveBeenCalledWith('my-team', {
|
||||
name: 'alice',
|
||||
role: 'developer',
|
||||
});
|
||||
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
||||
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
|
||||
'my-team'
|
||||
);
|
||||
|
||||
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
|
||||
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
|
||||
|
||||
result = (await removeHandler({} as never, 'my-team', 'alice')) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice');
|
||||
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
||||
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
|
||||
'my-team'
|
||||
);
|
||||
|
||||
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
|
||||
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
|
||||
|
||||
result = (await replaceHandler({} as never, 'my-team', {
|
||||
members: [{ name: 'bob', role: 'developer' }],
|
||||
})) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.replaceMembers).toHaveBeenCalledWith('my-team', {
|
||||
members: [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'developer',
|
||||
workflow: undefined,
|
||||
isolation: undefined,
|
||||
providerId: undefined,
|
||||
providerBackendId: undefined,
|
||||
model: undefined,
|
||||
effort: undefined,
|
||||
fastMode: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
||||
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
|
||||
'my-team'
|
||||
);
|
||||
|
||||
mockTeamDataWorkerClient.invalidateTeamConfig.mockClear();
|
||||
mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory.mockClear();
|
||||
|
||||
result = (await updateRoleHandler({} as never, 'my-team', 'bob', 'reviewer')) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.updateMemberRole).toHaveBeenCalledWith('my-team', 'bob', 'reviewer');
|
||||
expect(mockTeamDataWorkerClient.invalidateTeamConfig).toHaveBeenCalledWith('my-team');
|
||||
expect(mockTeamDataWorkerClient.invalidateMemberRuntimeAdvisory).toHaveBeenCalledWith(
|
||||
'my-team'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
|
|
@ -3671,7 +3741,10 @@ describe('ipc teams handlers', () => {
|
|||
try {
|
||||
const teamDir = path.join(claudeRoot, 'teams', 'anthropic-team');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(teamDir, 'config.json'), JSON.stringify({ teamName: 'anthropic-team' }));
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({ teamName: 'anthropic-team' })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -94,6 +94,30 @@ describe('OpenCodeRuntimeDeliveryAdvisoryPolicy', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('surfaces disk-full delivery failures immediately', () => {
|
||||
const record = makeRecord({
|
||||
responseState: 'empty_assistant_turn',
|
||||
lastReason: 'empty_assistant_turn',
|
||||
diagnostics: [
|
||||
"OpenCode message bridge failed: ENOSPC: no space left on device, open '/tmp/.auth.json.tmp'",
|
||||
'Latest assistant message msg_1 failed with MessageAbortedError - Aborted',
|
||||
'empty_assistant_turn',
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
decideOpenCodeRuntimeDeliveryAdvisory({
|
||||
record,
|
||||
now: Date.parse(record.failedAt!) + 1_000,
|
||||
})
|
||||
).toMatchObject({
|
||||
action: 'surface',
|
||||
severity: 'error',
|
||||
reasonCode: 'filesystem_error',
|
||||
reason: 'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.',
|
||||
});
|
||||
});
|
||||
|
||||
it('suppresses generic retryable tool errors before terminal failure', () => {
|
||||
const record = makeRecord({
|
||||
status: 'failed_retryable',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,25 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
|
|||
expect(selectOpenCodeRuntimeDeliveryReason(record)).toContain('Key limit exceeded');
|
||||
});
|
||||
|
||||
it('prioritizes local disk-full diagnostics over secondary aborted assistant errors', () => {
|
||||
const record = {
|
||||
diagnostics: [
|
||||
"OpenCode message bridge failed: ENOSPC: no space left on device, open '/tmp/.auth.json.tmp'",
|
||||
"ENOSPC: no space left on device, open '/tmp/.auth.json.tmp'",
|
||||
'OpenCode app MCP was reattached before message delivery.',
|
||||
'Latest assistant message msg_1 failed with MessageAbortedError - Aborted',
|
||||
'empty_assistant_turn',
|
||||
],
|
||||
lastReason: 'empty_assistant_turn',
|
||||
responseState: 'empty_assistant_turn',
|
||||
status: 'failed_terminal',
|
||||
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
|
||||
|
||||
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
|
||||
'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats non-visible tool progress failures without exposing the internal reason code', () => {
|
||||
const record = {
|
||||
diagnostics: ['non_visible_tool_without_task_progress'],
|
||||
|
|
|
|||
73
test/main/services/team/RuntimeDiagnosticClassifier.test.ts
Normal file
73
test/main/services/team/RuntimeDiagnosticClassifier.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
classifyRuntimeDiagnostic,
|
||||
selectRuntimeDiagnosticClassification,
|
||||
} from '../../../../src/main/services/team/runtime/RuntimeDiagnosticClassifier';
|
||||
|
||||
describe('RuntimeDiagnosticClassifier', () => {
|
||||
it('selects disk-full errors over aborted and empty OpenCode noise', () => {
|
||||
const selected = selectRuntimeDiagnosticClassification([
|
||||
'Latest assistant message msg_1 failed with MessageAbortedError - Aborted',
|
||||
'empty_assistant_turn',
|
||||
"OpenCode message bridge failed: ENOSPC: no space left on device, open '/tmp/.auth.json.tmp'",
|
||||
]);
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
reasonCode: 'filesystem_error',
|
||||
normalizedMessage: 'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.',
|
||||
actionRequired: true,
|
||||
generic: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects quota errors over empty assistant turns', () => {
|
||||
const selected = selectRuntimeDiagnosticClassification([
|
||||
'empty_assistant_turn',
|
||||
'Latest assistant message msg_2 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits',
|
||||
]);
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
reasonCode: 'quota_exhausted',
|
||||
normalizedMessage:
|
||||
'Insufficient credits. Add more using https://openrouter.ai/settings/credits',
|
||||
actionRequired: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects auth errors over bridge timeouts', () => {
|
||||
const selected = selectRuntimeDiagnosticClassification([
|
||||
'OpenCode bridge command timed out',
|
||||
'authentication_failed: invalid API key',
|
||||
]);
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
reasonCode: 'auth_error',
|
||||
normalizedMessage: 'authentication_failed: invalid API key',
|
||||
actionRequired: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps pure empty assistant turns as generic backend fallback', () => {
|
||||
expect(classifyRuntimeDiagnostic('empty_assistant_turn')).toMatchObject({
|
||||
reasonCode: 'backend_error',
|
||||
normalizedMessage: 'empty_assistant_turn',
|
||||
generic: true,
|
||||
actionRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps protocol proof failures above generic runtime noise', () => {
|
||||
const selected = selectRuntimeDiagnosticClassification([
|
||||
'OpenCode bridge command timed out',
|
||||
'visible_reply_missing_task_refs',
|
||||
]);
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
reasonCode: 'protocol_proof_missing',
|
||||
normalizedMessage: 'visible_reply_missing_task_refs',
|
||||
generic: true,
|
||||
actionRequired: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -169,6 +169,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
|
|||
],
|
||||
['codex_native_timeout', 'Codex native exec timed out after 120000ms.'],
|
||||
['network_error', 'Fetch failed because the network connection timed out.'],
|
||||
['filesystem_error', 'ENOSPC: no space left on device, write'],
|
||||
['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'],
|
||||
['protocol_proof_missing', 'OpenCode created a reply without the required taskRefs metadata.'],
|
||||
['backend_error', 'Unexpected backend blew up during request processing.'],
|
||||
|
|
|
|||
|
|
@ -578,4 +578,47 @@ describe('ActivityItem legacy system message fallback', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders member work sync nudges as a compact automation row', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
const message: InboxMessage = {
|
||||
from: 'system',
|
||||
to: 'tom',
|
||||
text: [
|
||||
'Work sync check: you have current actionable work assigned.',
|
||||
'Required sync action: call member_work_sync_status with teamName "launchpad".',
|
||||
'Then call member_work_sync_report with reportToken.',
|
||||
].join('\n'),
|
||||
summary: 'Work sync check',
|
||||
timestamp: new Date('2026-04-13T13:36:00.000Z').toISOString(),
|
||||
read: true,
|
||||
source: 'system_notification',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
messageId: 'member-work-sync:launchpad:tom:agenda-a',
|
||||
taskRefs: [{ taskId: 'task-a', displayId: '#b63b9065', teamName: 'launchpad' }],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(ActivityItem, { message, teamName: 'launchpad' }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('automation');
|
||||
expect(host.textContent).toContain('work sync');
|
||||
expect(host.textContent).toContain('tom');
|
||||
expect(host.textContent).toContain('#b63b9065');
|
||||
expect(host.textContent).not.toContain('member_work_sync_status');
|
||||
expect(host.textContent).not.toContain('member_work_sync_report');
|
||||
expect(host.textContent).not.toContain('reportToken');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,13 +72,15 @@ const basePreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
|||
],
|
||||
]);
|
||||
let mockedPreviewsByMember = basePreviewsByMember;
|
||||
let mockedLoading = false;
|
||||
let mockedError: string | null = null;
|
||||
|
||||
vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({
|
||||
buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }),
|
||||
useGraphMemberLogPreviews: () => ({
|
||||
previewsByMember: mockedPreviewsByMember,
|
||||
loading: false,
|
||||
error: null,
|
||||
loading: mockedLoading,
|
||||
error: mockedError,
|
||||
reload: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
|
@ -114,6 +116,8 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z'));
|
||||
mockedPreviewsByMember = basePreviewsByMember;
|
||||
mockedLoading = false;
|
||||
mockedError = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -645,6 +649,89 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows loading for empty previews while preserving unsupported provider text', async () => {
|
||||
const codexNode: GraphNode = {
|
||||
id: 'member:alpha-team:codex-dev',
|
||||
kind: 'member',
|
||||
label: 'codex-dev',
|
||||
state: 'idle',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'codex-dev' },
|
||||
};
|
||||
const quietNode: GraphNode = {
|
||||
id: 'member:alpha-team:quiet-dev',
|
||||
kind: 'member',
|
||||
label: 'quiet-dev',
|
||||
state: 'idle',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'quiet-dev' },
|
||||
};
|
||||
mockedLoading = true;
|
||||
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
||||
[
|
||||
'codex-dev',
|
||||
{
|
||||
memberName: 'codex-dev',
|
||||
items: [],
|
||||
coverage: [{ provider: 'codex_native_trace', status: 'skipped' }],
|
||||
warnings: [
|
||||
{
|
||||
code: 'codex_member_wide_not_supported',
|
||||
message: 'Codex member-wide native trace is not available in this variant yet.',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
[
|
||||
'quiet-dev',
|
||||
{
|
||||
memberName: 'quiet-dev',
|
||||
items: [],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'skipped' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[codexNode, quietNode]}
|
||||
getLogWorldRect={(ownerNodeId) => ({
|
||||
left: ownerNodeId.includes('quiet') ? 360 : 40,
|
||||
top: 80,
|
||||
right: ownerNodeId.includes('quiet') ? 620 : 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Unsupported provider');
|
||||
expect(host.textContent).toContain('Loading logs');
|
||||
expect(host.textContent).not.toContain('No recent logs');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders lead log previews and opens the lead profile logs tab', async () => {
|
||||
const leadNode: GraphNode = {
|
||||
id: 'lead:alpha-team',
|
||||
|
|
|
|||
|
|
@ -4,8 +4,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
import { useStore } from '@renderer/store';
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('span', null, children),
|
||||
Badge: ({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) => React.createElement('span', { className, style }, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
|
|
@ -132,6 +139,47 @@ describe('GraphNodePopover spawn badge labels', () => {
|
|||
});
|
||||
|
||||
expect(host.textContent).toContain('spawn failed');
|
||||
expect(
|
||||
Array.from(host.querySelectorAll('span')).some(
|
||||
(badge) =>
|
||||
badge.textContent === 'spawn failed' && badge.className.includes('text-red-300')
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders launch exception status text in red when it is the primary status', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(GraphNodePopover, {
|
||||
node: {
|
||||
...makeMemberNode('online'),
|
||||
launchStatusLabel: 'OpenCode API error',
|
||||
exceptionTone: 'error',
|
||||
exceptionLabel: 'OpenCode API error',
|
||||
},
|
||||
teamName: 'northstar-core',
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
Array.from(host.querySelectorAll('span')).some(
|
||||
(badge) =>
|
||||
badge.textContent === 'OpenCode API error' && badge.className.includes('text-red-300')
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -172,9 +172,16 @@ describe('TeamGraphAdapter particles', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'grid-under-lead'
|
||||
'radial'
|
||||
);
|
||||
|
||||
expect(graph.layout?.mode).toBe('radial');
|
||||
});
|
||||
|
||||
it('defaults the graph layout mode to rows', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const graph = adapter.adapt(createBaseTeamData(), 'my-team');
|
||||
|
||||
expect(graph.layout?.mode).toBe('grid-under-lead');
|
||||
});
|
||||
|
||||
|
|
@ -487,7 +494,8 @@ describe('TeamGraphAdapter particles', () => {
|
|||
undefined,
|
||||
{
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
||||
}
|
||||
},
|
||||
'radial'
|
||||
);
|
||||
|
||||
expect(graph.layout?.ownerOrder).toEqual([
|
||||
|
|
@ -552,7 +560,8 @@ describe('TeamGraphAdapter particles', () => {
|
|||
undefined,
|
||||
{
|
||||
'agent-alice': { ringIndex: 1, sectorIndex: 4 },
|
||||
}
|
||||
},
|
||||
'radial'
|
||||
);
|
||||
|
||||
expect(graph.layout?.ownerOrder).toEqual([
|
||||
|
|
|
|||
|
|
@ -23,11 +23,21 @@ interface FillTextCall {
|
|||
globalAlpha: number;
|
||||
}
|
||||
|
||||
interface GradientStopCall {
|
||||
offset: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function createMockContext() {
|
||||
const fillTextCalls: FillTextCall[] = [];
|
||||
const strokeTextCalls: FillTextCall[] = [];
|
||||
const roundRectCalls: Array<{ x: number; y: number; width: number; height: number }> = [];
|
||||
const gradient = { addColorStop: vi.fn() };
|
||||
const roundRectCalls: { x: number; y: number; width: number; height: number }[] = [];
|
||||
const gradientStops: GradientStopCall[] = [];
|
||||
const gradient = {
|
||||
addColorStop: vi.fn((offset: number, color: string) => {
|
||||
gradientStops.push({ offset, color });
|
||||
}),
|
||||
};
|
||||
let fillStyle = '';
|
||||
let globalAlpha = 1;
|
||||
|
||||
|
|
@ -84,7 +94,7 @@ function createMockContext() {
|
|||
},
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
|
||||
return { ctx, fillTextCalls, strokeTextCalls, roundRectCalls };
|
||||
return { ctx, fillTextCalls, strokeTextCalls, roundRectCalls, gradientStops };
|
||||
}
|
||||
|
||||
describe('drawAgents', () => {
|
||||
|
|
@ -215,4 +225,25 @@ describe('drawAgents', () => {
|
|||
2
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a red glow around members with error exceptions', () => {
|
||||
const { ctx, gradientStops } = createMockContext();
|
||||
const node: GraphNode = {
|
||||
id: 'member:demo:bob',
|
||||
kind: 'member',
|
||||
label: 'bob',
|
||||
state: 'active',
|
||||
color: '#7c3aed',
|
||||
exceptionTone: 'error',
|
||||
exceptionLabel: 'OpenCode API error',
|
||||
domainRef: { kind: 'member', teamName: 'demo', memberName: 'bob' },
|
||||
x: 320,
|
||||
y: 240,
|
||||
};
|
||||
|
||||
drawAgents(ctx, [node], 0, null, null, null, 1);
|
||||
|
||||
expect(ctx.createRadialGradient).toHaveBeenCalledWith(320, 240, 18, 320, 240, 50);
|
||||
expect(gradientStops.some((stop) => stop.color.startsWith('#ef4444'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,12 +22,15 @@ vi.mock('@renderer/api', () => ({
|
|||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||
resolve = innerResolve;
|
||||
reject = innerReject;
|
||||
});
|
||||
return { promise, resolve };
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function response(memberName: string, generatedAt: string): MemberLogPreviewResponse {
|
||||
|
|
@ -57,6 +60,23 @@ function response(memberName: string, generatedAt: string): MemberLogPreviewResp
|
|||
};
|
||||
}
|
||||
|
||||
function emptyResponse(memberName: string, generatedAt: string): MemberLogPreviewResponse {
|
||||
return {
|
||||
generatedAt,
|
||||
members: [
|
||||
{
|
||||
memberName,
|
||||
items: [],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'skipped' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function batchResponse(memberNames: string[], generatedAt: string): MemberLogPreviewResponse {
|
||||
return {
|
||||
generatedAt,
|
||||
|
|
@ -107,6 +127,28 @@ const HookProbe = ({
|
|||
return null;
|
||||
};
|
||||
|
||||
const ReloadProbe = ({
|
||||
teamName,
|
||||
memberNames,
|
||||
onState,
|
||||
onReload,
|
||||
}: {
|
||||
teamName: string;
|
||||
memberNames: string[];
|
||||
onState: (state: ReturnType<typeof useGraphMemberLogPreviews>) => void;
|
||||
onReload: (reload: ReturnType<typeof useGraphMemberLogPreviews>['reload']) => void;
|
||||
}): React.JSX.Element | null => {
|
||||
const state = useGraphMemberLogPreviews({
|
||||
teamName,
|
||||
memberNames,
|
||||
});
|
||||
useEffect(() => {
|
||||
onState(state);
|
||||
onReload(state.reload);
|
||||
}, [onReload, onState, state]);
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('useGraphMemberLogPreviews', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -162,6 +204,7 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
expect.objectContaining({
|
||||
maxItemsPerMember: 3,
|
||||
textLimit: 200,
|
||||
forceRefresh: true,
|
||||
laneIdsByMember: { alice: 'secondary:opencode:alice' },
|
||||
})
|
||||
);
|
||||
|
|
@ -171,6 +214,104 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows loading while the first visible preview request is still debounced', async () => {
|
||||
const firstLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockReturnValueOnce(firstLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
firstLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not duplicate the initial debounced request in React StrictMode', async () => {
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(
|
||||
response('alice', '2026-04-03T00:00:00.000Z')
|
||||
);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => undefined} />
|
||||
</React.StrictMode>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a scheduled preview request when unmounted before the debounce fires', async () => {
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(
|
||||
response('alice', '2026-04-03T00:00:00.000Z')
|
||||
);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => undefined} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps completed previews cached after the visible member set changes', async () => {
|
||||
const aliceLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
const bobLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
|
|
@ -278,6 +419,68 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not show stale previews as loaded after switching teams with the same visible member', async () => {
|
||||
const betaLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockResolvedValueOnce(response('alice', '2026-04-03T00:00:00.000Z'))
|
||||
.mockReturnValueOnce(betaLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe(
|
||||
'alice:2026-04-03T00:00:00.000Z'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="beta-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
expect(latestState()?.previewsByMember.get('alice')).toBeUndefined();
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'beta-team',
|
||||
['alice'],
|
||||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
betaLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe(
|
||||
'alice:2026-04-03T00:01:00.000Z'
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not duplicate preview requests when the same visible members are reordered', async () => {
|
||||
const firstLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockReturnValueOnce(firstLoad.promise);
|
||||
|
|
@ -409,6 +612,637 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not reload when only a non-visible member lane changes', async () => {
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(
|
||||
response('alice', '2026-04-03T00:00:00.000Z')
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe
|
||||
teamName="alpha-team"
|
||||
memberNames={['alice']}
|
||||
laneIdsByMember={{
|
||||
alice: 'secondary:opencode:alice',
|
||||
bob: 'secondary:opencode:bob:old',
|
||||
}}
|
||||
onState={() => undefined}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe
|
||||
teamName="alpha-team"
|
||||
memberNames={['alice']}
|
||||
laneIdsByMember={{
|
||||
alice: 'secondary:opencode:alice',
|
||||
bob: 'secondary:opencode:bob:new',
|
||||
}}
|
||||
onState={() => undefined}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to normalized lane ids when an exact member key is blank', async () => {
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(
|
||||
response('Alice', '2026-04-03T00:00:00.000Z')
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe
|
||||
teamName="alpha-team"
|
||||
memberNames={['Alice']}
|
||||
laneIdsByMember={{
|
||||
Alice: ' ',
|
||||
alice: 'secondary:opencode:alice',
|
||||
}}
|
||||
onState={() => undefined}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['Alice'],
|
||||
expect.objectContaining({
|
||||
laneIdsByMember: {
|
||||
Alice: 'secondary:opencode:alice',
|
||||
alice: 'secondary:opencode:alice',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a pending forced reload when lane metadata rerenders before debounce fires', async () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
| null = null;
|
||||
apiMock.teams.onTeamChange.mockImplementation((callback) => {
|
||||
teamChangeListener = callback as typeof teamChangeListener;
|
||||
return () => undefined;
|
||||
});
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(
|
||||
response('alice', '2026-04-03T00:00:00.000Z')
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe
|
||||
teamName="alpha-team"
|
||||
memberNames={['alice']}
|
||||
laneIdsByMember={{ alice: 'secondary:opencode:alice:old' }}
|
||||
onState={() => undefined}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' });
|
||||
root.render(
|
||||
<HookProbe
|
||||
teamName="alpha-team"
|
||||
memberNames={['alice']}
|
||||
laneIdsByMember={{ alice: 'secondary:opencode:alice:new' }}
|
||||
onState={() => undefined}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({
|
||||
forceRefresh: true,
|
||||
laneIdsByMember: { alice: 'secondary:opencode:alice:new' },
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('force refreshes visible previews after returning from a hidden document', async () => {
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(
|
||||
response('alice', '2026-04-03T00:00:00.000Z')
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => undefined} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'hidden',
|
||||
});
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'visible',
|
||||
});
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('marks empty cached previews as loading while a forced event refresh is pending', async () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
| null = null;
|
||||
const refreshLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.teams.onTeamChange.mockImplementation((callback) => {
|
||||
teamChangeListener = callback as typeof teamChangeListener;
|
||||
return () => undefined;
|
||||
});
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockResolvedValueOnce(emptyResponse('alice', '2026-04-03T00:00:00.000Z'))
|
||||
.mockReturnValueOnce(refreshLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
refreshLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps loading when an empty visible response arrives before a pending forced refresh starts', async () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
| null = null;
|
||||
const initialLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
const refreshLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.teams.onTeamChange.mockImplementation((callback) => {
|
||||
teamChangeListener = callback as typeof teamChangeListener;
|
||||
return () => undefined;
|
||||
});
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockReturnValueOnce(initialLoad.promise)
|
||||
.mockReturnValueOnce(refreshLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
initialLoad.resolve(emptyResponse('alice', '2026-04-03T00:00:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
refreshLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('marks empty cached previews as loading during a direct forced reload', async () => {
|
||||
const refreshLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockResolvedValueOnce(emptyResponse('alice', '2026-04-03T00:00:00.000Z'))
|
||||
.mockReturnValueOnce(refreshLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
let reload: ReturnType<typeof useGraphMemberLogPreviews>['reload'] | null = null;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ReloadProbe
|
||||
teamName="alpha-team"
|
||||
memberNames={['alice']}
|
||||
onState={onState}
|
||||
onReload={(nextReload) => {
|
||||
reload = nextReload;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
void reload?.({ background: true, forceRefresh: true });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
refreshLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps loading and ignores stale errors while a newer empty-preview refresh is in flight', async () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
| null = null;
|
||||
const staleRefresh = createDeferred<MemberLogPreviewResponse>();
|
||||
const latestRefresh = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.teams.onTeamChange.mockImplementation((callback) => {
|
||||
teamChangeListener = callback as typeof teamChangeListener;
|
||||
return () => undefined;
|
||||
});
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockResolvedValueOnce(emptyResponse('alice', '2026-04-03T00:00:00.000Z'))
|
||||
.mockReturnValueOnce(staleRefresh.promise)
|
||||
.mockReturnValueOnce(latestRefresh.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe
|
||||
teamName="alpha-team"
|
||||
memberNames={['alice']}
|
||||
laneIdsByMember={{ alice: 'secondary:opencode:alice:old' }}
|
||||
onState={onState}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' });
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe
|
||||
teamName="alpha-team"
|
||||
memberNames={['alice']}
|
||||
laneIdsByMember={{ alice: 'secondary:opencode:alice:new' }}
|
||||
onState={onState}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(3);
|
||||
|
||||
await act(async () => {
|
||||
staleRefresh.reject(new Error('stale lane failed'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
expect(latestState()?.error).toBeNull();
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
latestRefresh.resolve(response('alice', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.error).toBeNull();
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores hidden member request loading and errors after the visible member changes', async () => {
|
||||
const hiddenAliceLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
const visibleBobLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockReturnValueOnce(hiddenAliceLoad.promise)
|
||||
.mockReturnValueOnce(visibleBobLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['bob']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
hiddenAliceLoad.reject(new Error('hidden alice failed before bob starts'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
expect(latestState()?.error).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
visibleBobLoad.resolve(emptyResponse('bob', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.error).toBeNull();
|
||||
expect(latestState()?.previewsByMember.get('bob')?.items).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores old same-key responses after switching away from and back to a team', async () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
| null = null;
|
||||
const oldAlphaLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
const currentAlphaLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.teams.onTeamChange.mockImplementation((callback) => {
|
||||
teamChangeListener = callback as typeof teamChangeListener;
|
||||
return () => undefined;
|
||||
});
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockReturnValueOnce(oldAlphaLoad.promise)
|
||||
.mockReturnValueOnce(currentAlphaLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="beta-team" memberNames={[]} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.size).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
oldAlphaLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(true);
|
||||
expect(latestState()?.previewsByMember.get('alice')).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' });
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
currentAlphaLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.loading).toBe(false);
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe(
|
||||
'alice:2026-04-03T00:01:00.000Z'
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads visible members on log change events with force refresh', async () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
|
|
@ -450,7 +1284,7 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' });
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' });
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
|
@ -462,6 +1296,19 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' });
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(4);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ describe('stable slot layout planner', () => {
|
|||
if (!snapshot) {
|
||||
throw new Error('Expected stable slot layout snapshot');
|
||||
}
|
||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
expect(validateStableSlotLayout(snapshot)).toEqual({ valid: true });
|
||||
|
||||
for (const frame of snapshot.memberSlotFrames) {
|
||||
for (const centralRect of snapshot.centralCollisionRects) {
|
||||
|
|
@ -334,8 +334,8 @@ describe('stable slot layout planner', () => {
|
|||
}
|
||||
}
|
||||
|
||||
for (const [index, left] of snapshot!.memberSlotFrames.entries()) {
|
||||
for (const right of snapshot!.memberSlotFrames.slice(index + 1)) {
|
||||
for (const [index, left] of snapshot.memberSlotFrames.entries()) {
|
||||
for (const right of snapshot.memberSlotFrames.slice(index + 1)) {
|
||||
if (!rectsOverlapVertically(left.bounds, right.bounds)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -971,6 +971,46 @@ describe('stable slot layout planner', () => {
|
|||
expect(frames[0].processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight);
|
||||
});
|
||||
|
||||
it('keeps six grid-under-lead members in two-column rows', () => {
|
||||
const teamName = 'team-grid-six';
|
||||
const lead = createLead(teamName);
|
||||
const members = [
|
||||
createMember(teamName, 'agent-alice', 'alice'),
|
||||
createMember(teamName, 'agent-bob', 'bob'),
|
||||
createMember(teamName, 'agent-tom', 'tom'),
|
||||
createMember(teamName, 'agent-jack', 'jack'),
|
||||
createMember(teamName, 'agent-eve', 'eve'),
|
||||
createMember(teamName, 'agent-sam', 'sam'),
|
||||
];
|
||||
const layout: GraphLayoutPort = {
|
||||
version: 'stable-slots-v1',
|
||||
mode: 'grid-under-lead',
|
||||
ownerOrder: members.map((member) => member.id),
|
||||
slotAssignments: {},
|
||||
};
|
||||
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
nodes: [lead, ...members],
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
|
||||
const frames = snapshot!.memberSlotFrames;
|
||||
expect(frames).toHaveLength(6);
|
||||
expect(frames[0].ownerY).toBe(frames[1].ownerY);
|
||||
expect(frames[2].ownerY).toBe(frames[3].ownerY);
|
||||
expect(frames[4].ownerY).toBe(frames[5].ownerY);
|
||||
expect(frames[2].ownerY).toBeGreaterThan(frames[0].ownerY);
|
||||
expect(frames[4].ownerY).toBeGreaterThan(frames[2].ownerY);
|
||||
expect(frames[0].ownerX).toBeLessThan(0);
|
||||
expect(frames[1].ownerX).toBeGreaterThan(0);
|
||||
expect(frames[4].ownerX).toBeLessThan(0);
|
||||
expect(frames[5].ownerX).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps wide grid-under-lead rows from overlapping horizontally', () => {
|
||||
const teamName = 'team-grid-wide';
|
||||
const lead = createLead(teamName);
|
||||
|
|
|
|||
|
|
@ -768,22 +768,22 @@ describe('teamSlice actions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('stores graph layout mode without mutating radial slot assignments', () => {
|
||||
it('stores non-default graph layout mode without mutating radial slot assignments', () => {
|
||||
const store = createSliceStore();
|
||||
store
|
||||
.getState()
|
||||
.commitTeamGraphOwnerSlotDrop('my-team', 'agent-alice', { ringIndex: 0, sectorIndex: 2 });
|
||||
|
||||
store.getState().setTeamGraphLayoutMode('my-team', 'grid-under-lead');
|
||||
store.getState().setTeamGraphLayoutMode('my-team', 'radial');
|
||||
|
||||
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('grid-under-lead');
|
||||
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('radial');
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
||||
});
|
||||
|
||||
store.getState().setTeamGraphLayoutMode('my-team', 'radial');
|
||||
store.getState().setTeamGraphLayoutMode('my-team', 'grid-under-lead');
|
||||
|
||||
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('radial');
|
||||
expect(store.getState().graphLayoutModeByTeam['my-team']).toBe('grid-under-lead');
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 2 },
|
||||
});
|
||||
|
|
@ -2486,6 +2486,51 @@ describe('teamSlice actions', () => {
|
|||
expect(selectResolvedMembersForTeamName(store.getState(), 'my-team')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to config roster when snapshot members are temporarily empty', () => {
|
||||
const store = createSliceStore();
|
||||
const partialSnapshot = createTeamSnapshot({
|
||||
config: {
|
||||
name: 'My Team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
|
||||
{ name: 'alice', role: 'reviewer', providerId: 'anthropic', color: 'blue' },
|
||||
{ name: 'bob', role: 'developer', providerId: 'opencode' },
|
||||
],
|
||||
},
|
||||
members: [],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
subject: 'Review current diff',
|
||||
status: 'in_progress',
|
||||
owner: 'alice',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
store.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
selectedTeamData: partialSnapshot,
|
||||
teamDataCacheByName: {
|
||||
'my-team': partialSnapshot,
|
||||
},
|
||||
memberActivityMetaByTeam: {},
|
||||
});
|
||||
|
||||
const members = selectResolvedMembersForTeamName(store.getState(), 'my-team');
|
||||
|
||||
expect(members.map((member) => member.name)).toEqual(['team-lead', 'alice', 'bob']);
|
||||
expect(members.find((member) => member.name === 'alice')).toMatchObject({
|
||||
role: 'reviewer',
|
||||
currentTaskId: 'task-1',
|
||||
taskCount: 1,
|
||||
});
|
||||
expect(selectResolvedMemberForTeamName(store.getState(), 'my-team', 'bob')).toMatchObject({
|
||||
name: 'bob',
|
||||
role: 'developer',
|
||||
});
|
||||
});
|
||||
|
||||
it('memoizes team-scoped member messages selectors over the merged message feed', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
|
|
|
|||
|
|
@ -763,6 +763,20 @@ describe('memberHelpers spawn-aware presence', () => {
|
|||
).toContain('Connection timed out while contacting provider.');
|
||||
});
|
||||
|
||||
it('renders local filesystem advisories as disk space errors', () => {
|
||||
const advisory = {
|
||||
kind: 'api_error' as const,
|
||||
observedAt: '2026-04-07T09:00:00.000Z',
|
||||
reasonCode: 'filesystem_error' as const,
|
||||
message: 'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.',
|
||||
};
|
||||
|
||||
expect(getMemberRuntimeAdvisoryLabel(advisory, 'opencode')).toBe('Disk space error');
|
||||
expect(getMemberRuntimeAdvisoryTitle(advisory, 'opencode')).toContain(
|
||||
'Local disk is full or unavailable.'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders terminal API errors as errors instead of retrying status', () => {
|
||||
expect(
|
||||
getMemberRuntimeAdvisoryLabel(
|
||||
|
|
|
|||
|
|
@ -585,6 +585,32 @@ Messages:
|
|||
expect(result.map((message) => message.messageId)).toEqual(['msg-2']);
|
||||
});
|
||||
|
||||
it('hides member work sync nudges from conversational message counts by default', () => {
|
||||
const messages = [
|
||||
makeMessage({
|
||||
messageId: 'member-work-sync:demo:jack:agenda-a',
|
||||
from: 'system',
|
||||
to: 'jack',
|
||||
source: 'system_notification',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
summary: 'Work sync check',
|
||||
text: 'Work sync check: call member_work_sync_status.',
|
||||
}),
|
||||
makeMessage({
|
||||
messageId: 'msg-2',
|
||||
text: 'Visible message',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterTeamMessages(messages, {
|
||||
timeWindow: null,
|
||||
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
expect(result.map((message) => message.messageId)).toEqual(['msg-2']);
|
||||
});
|
||||
|
||||
it('hides review pickup escalation automation rows from conversational message counts by default', () => {
|
||||
const messages = [
|
||||
makeMessage({
|
||||
|
|
@ -634,6 +660,31 @@ Messages:
|
|||
]);
|
||||
});
|
||||
|
||||
it('can include member work sync nudges for the activity timeline', () => {
|
||||
const messages = [
|
||||
makeMessage({
|
||||
messageId: 'member-work-sync:demo:jack:agenda-a',
|
||||
from: 'system',
|
||||
to: 'jack',
|
||||
source: 'system_notification',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
summary: 'Work sync check',
|
||||
text: 'Work sync check: call member_work_sync_status.',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterTeamMessages(messages, {
|
||||
includeAutomationEvents: true,
|
||||
timeWindow: null,
|
||||
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
expect(result.map((message) => message.messageId)).toEqual([
|
||||
'member-work-sync:demo:jack:agenda-a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps review pickup escalation hidden even when regular automation rows are included', () => {
|
||||
const messages = [
|
||||
makeMessage({
|
||||
|
|
|
|||
Loading…
Reference in a new issue