feat: docs + optmizitation + improve launch
This commit is contained in:
parent
5c65f55067
commit
d20fe2a538
59 changed files with 7371 additions and 660 deletions
136
landing/assets/styles/brand-tokens.css
Normal file
136
landing/assets/styles/brand-tokens.css
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--at-font-sans: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--at-font-mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
--at-c-cyan: #00f0ff;
|
||||
--at-c-cyan-strong: #00d4e6;
|
||||
--at-c-cyan-deep: #0891b2;
|
||||
--at-c-magenta: #ff00ff;
|
||||
--at-c-green: #39ff14;
|
||||
--at-c-gold: #ffd700;
|
||||
--at-c-red: #ff4757;
|
||||
|
||||
--at-c-dark-0: #05070b;
|
||||
--at-c-dark-1: #0a0a0f;
|
||||
--at-c-dark-2: #12121a;
|
||||
--at-c-dark-3: #1e293b;
|
||||
--at-c-light-0: #ffffff;
|
||||
--at-c-light-1: #f8fafc;
|
||||
--at-c-light-2: #f0f2f5;
|
||||
|
||||
--at-c-text-dark-1: #e0e6ff;
|
||||
--at-c-text-dark-2: #c8d6e5;
|
||||
--at-c-text-dark-3: #a0a8c0;
|
||||
--at-c-text-dark-muted: #8892b0;
|
||||
--at-c-text-light-1: #1e293b;
|
||||
--at-c-text-light-2: #475569;
|
||||
--at-c-text-light-3: #64748b;
|
||||
|
||||
--at-c-bg: var(--at-c-dark-1);
|
||||
--at-c-bg-soft: rgba(10, 10, 15, 0.8);
|
||||
--at-c-surface: var(--at-c-dark-2);
|
||||
--at-c-surface-soft: rgba(10, 10, 15, 0.6);
|
||||
--at-c-surface-raised: rgba(30, 41, 59, 0.78);
|
||||
--at-c-text: var(--at-c-text-dark-1);
|
||||
--at-c-text-soft: var(--at-c-text-dark-2);
|
||||
--at-c-text-muted: var(--at-c-text-dark-muted);
|
||||
--at-c-border: rgba(0, 240, 255, 0.12);
|
||||
--at-c-border-strong: rgba(0, 240, 255, 0.28);
|
||||
--at-c-focus: rgba(0, 240, 255, 0.55);
|
||||
|
||||
--at-gradient-brand: linear-gradient(135deg, var(--at-c-cyan), var(--at-c-magenta));
|
||||
--at-gradient-brand-text: linear-gradient(135deg, #e0e6ff 0%, var(--at-c-cyan) 50%, var(--at-c-magenta) 100%);
|
||||
--at-gradient-success: linear-gradient(135deg, var(--at-c-cyan), var(--at-c-green));
|
||||
--at-gradient-cyan-text: linear-gradient(135deg, #e0e6ff 0%, var(--at-c-cyan) 100%);
|
||||
--at-gradient-panel: linear-gradient(135deg, rgba(0, 240, 255, 0.06), rgba(255, 0, 255, 0.035));
|
||||
|
||||
--at-radius-xs: 6px;
|
||||
--at-radius-sm: 8px;
|
||||
--at-radius-md: 10px;
|
||||
--at-radius-lg: 12px;
|
||||
--at-radius-xl: 16px;
|
||||
--at-radius-2xl: 20px;
|
||||
--at-radius-preview: 22px;
|
||||
--at-radius-pill: 999px;
|
||||
|
||||
--at-shadow-cyan-sm: 0 4px 20px rgba(0, 240, 255, 0.3);
|
||||
--at-shadow-cyan-md: 0 8px 32px rgba(0, 240, 255, 0.08);
|
||||
--at-shadow-cyan-lg: 0 20px 60px rgba(0, 0, 0, 0.55), 0 0 30px rgba(0, 240, 255, 0.06);
|
||||
--at-shadow-card: 0 16px 32px -10px rgba(0, 0, 0, 0.35);
|
||||
|
||||
--at-blur-sm: 8px;
|
||||
--at-blur-md: 12px;
|
||||
--at-blur-lg: 20px;
|
||||
--at-glass-bg: rgba(10, 10, 15, 0.78);
|
||||
--at-glass-bg-hover: rgba(10, 10, 15, 0.9);
|
||||
--at-glass-border: 1px solid var(--at-c-border);
|
||||
--at-glass-border-strong: 1px solid var(--at-c-border-strong);
|
||||
|
||||
--at-grid-line: rgba(0, 240, 255, 0.03);
|
||||
--at-scanline: rgba(0, 240, 255, 0.008);
|
||||
--at-transition-fast: 0.15s ease;
|
||||
--at-transition-base: 0.25s ease;
|
||||
--at-transition-smooth: 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
--at-z-header: 1000;
|
||||
}
|
||||
|
||||
.v-theme--light,
|
||||
:root:not(.dark) {
|
||||
--at-c-bg: var(--at-c-light-2);
|
||||
--at-c-bg-soft: rgba(255, 255, 255, 0.82);
|
||||
--at-c-surface: var(--at-c-light-0);
|
||||
--at-c-surface-soft: rgba(255, 255, 255, 0.78);
|
||||
--at-c-surface-raised: rgba(255, 255, 255, 0.92);
|
||||
--at-c-text: var(--at-c-text-light-1);
|
||||
--at-c-text-soft: var(--at-c-text-light-2);
|
||||
--at-c-text-muted: var(--at-c-text-light-3);
|
||||
--at-c-border: rgba(0, 0, 0, 0.08);
|
||||
--at-c-border-strong: rgba(0, 139, 178, 0.3);
|
||||
--at-c-focus: rgba(8, 145, 178, 0.5);
|
||||
--at-glass-bg: rgba(255, 255, 255, 0.78);
|
||||
--at-glass-bg-hover: rgba(255, 255, 255, 0.92);
|
||||
--at-glass-border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
--at-glass-border-strong: 1px solid rgba(0, 139, 178, 0.25);
|
||||
--at-shadow-card: 0 16px 32px -10px rgba(0, 0, 0, 0.12);
|
||||
--at-shadow-cyan-lg: 0 20px 60px rgba(0, 180, 200, 0.12);
|
||||
--at-grid-line: rgba(8, 145, 178, 0.045);
|
||||
}
|
||||
|
||||
.v-theme--dark,
|
||||
.dark {
|
||||
--at-c-bg: var(--at-c-dark-1);
|
||||
--at-c-bg-soft: rgba(10, 10, 15, 0.8);
|
||||
--at-c-surface: var(--at-c-dark-2);
|
||||
--at-c-surface-soft: rgba(10, 10, 15, 0.6);
|
||||
--at-c-surface-raised: rgba(30, 41, 59, 0.78);
|
||||
--at-c-text: var(--at-c-text-dark-1);
|
||||
--at-c-text-soft: var(--at-c-text-dark-2);
|
||||
--at-c-text-muted: var(--at-c-text-dark-muted);
|
||||
--at-c-border: rgba(0, 240, 255, 0.12);
|
||||
--at-c-border-strong: rgba(0, 240, 255, 0.28);
|
||||
--at-glass-bg: rgba(10, 10, 15, 0.78);
|
||||
--at-glass-bg-hover: rgba(10, 10, 15, 0.92);
|
||||
}
|
||||
|
||||
.at-gradient-text {
|
||||
background: var(--at-gradient-brand-text);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.at-glass {
|
||||
background: var(--at-glass-bg);
|
||||
border: var(--at-glass-border);
|
||||
backdrop-filter: blur(var(--at-blur-md));
|
||||
-webkit-backdrop-filter: blur(var(--at-blur-md));
|
||||
box-shadow: var(--at-shadow-cyan-md);
|
||||
}
|
||||
|
||||
.at-focus-ring:focus-visible {
|
||||
outline: 2px solid var(--at-c-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
@import "./brand-tokens.css";
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
font-family: var(--at-font-sans);
|
||||
background: rgb(var(--v-theme-background));
|
||||
color: rgb(var(--v-theme-on-background));
|
||||
}
|
||||
|
|
@ -32,7 +34,7 @@ body {
|
|||
|
||||
/* Monospace accent font for technical elements */
|
||||
.mono {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-family: var(--at-font-mono);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
|
|
@ -72,5 +74,5 @@ body {
|
|||
}
|
||||
|
||||
.app-header {
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(var(--at-blur-md));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@
|
|||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 240, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px);
|
||||
linear-gradient(var(--at-grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--at-grid-line) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
|
@ -40,8 +40,8 @@
|
|||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 240, 255, 0.008) 2px,
|
||||
rgba(0, 240, 255, 0.008) 4px
|
||||
var(--at-scanline) 2px,
|
||||
var(--at-scanline) 4px
|
||||
);
|
||||
z-index: 2;
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
.page-bg__orb--1 {
|
||||
width: 900px;
|
||||
height: 900px;
|
||||
background: #00f0ff;
|
||||
background: var(--at-c-cyan);
|
||||
top: -200px;
|
||||
right: -150px;
|
||||
animation: orbDrift1 20s ease-in-out infinite;
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
.page-bg__orb--2 {
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
background: #ff00ff;
|
||||
background: var(--at-c-magenta);
|
||||
top: 300px;
|
||||
left: -200px;
|
||||
animation: orbDrift2 25s ease-in-out infinite;
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
.page-bg__orb--3 {
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
background: #39ff14;
|
||||
background: var(--at-c-green);
|
||||
top: 1200px;
|
||||
right: -100px;
|
||||
opacity: 0.05;
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
.page-bg__orb--4 {
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
background: #00f0ff;
|
||||
background: var(--at-c-cyan);
|
||||
top: 2100px;
|
||||
left: -150px;
|
||||
opacity: 0.06;
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
.page-bg__orb--5 {
|
||||
width: 750px;
|
||||
height: 750px;
|
||||
background: #ff00ff;
|
||||
background: var(--at-c-magenta);
|
||||
top: 2900px;
|
||||
right: -120px;
|
||||
opacity: 0.05;
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
.page-bg__orb--6 {
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
background: #ffd700;
|
||||
background: var(--at-c-gold);
|
||||
top: 3600px;
|
||||
left: -100px;
|
||||
opacity: 0.04;
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
.page-bg__orb--7 {
|
||||
width: 650px;
|
||||
height: 650px;
|
||||
background: #00f0ff;
|
||||
background: var(--at-c-cyan);
|
||||
top: 4300px;
|
||||
right: -80px;
|
||||
opacity: 0.05;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { repoUrl } = useGithubRepo();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
const year = new Date().getFullYear();
|
||||
const docsHref = computed(() => {
|
||||
const base = baseURL.replace(/\/?$/, '/');
|
||||
return `${base}${locale.value === 'ru' ? 'docs/ru/' : 'docs/'}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -14,6 +19,8 @@ const year = new Date().getFullYear();
|
|||
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">Author</a>
|
||||
<span class="app-footer__divider" />
|
||||
<a class="app-footer__link" :href="repoUrl" target="_blank">GitHub</a>
|
||||
<span class="app-footer__divider" />
|
||||
<a class="app-footer__link" :href="docsHref">{{ t('footer.links.docs') }}</a>
|
||||
</div>
|
||||
</v-container>
|
||||
</footer>
|
||||
|
|
@ -21,7 +28,7 @@ const year = new Date().getFullYear();
|
|||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
border-top: 1px solid rgba(0, 240, 255, 0.08);
|
||||
border-top: 1px solid var(--at-c-border);
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +41,7 @@ const year = new Date().getFullYear();
|
|||
.app-footer__copy {
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--at-font-mono);
|
||||
}
|
||||
|
||||
.app-footer__links {
|
||||
|
|
@ -44,12 +51,12 @@ const year = new Date().getFullYear();
|
|||
}
|
||||
|
||||
.app-footer__link {
|
||||
color: #00f0ff;
|
||||
color: var(--at-c-cyan);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--at-font-mono);
|
||||
}
|
||||
|
||||
.app-footer__link:hover {
|
||||
|
|
@ -59,11 +66,11 @@ const year = new Date().getFullYear();
|
|||
.app-footer__divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: rgba(0, 240, 255, 0.2);
|
||||
background: var(--at-c-border-strong);
|
||||
}
|
||||
|
||||
.v-theme--light .app-footer {
|
||||
border-top-color: rgba(0, 0, 0, 0.08);
|
||||
border-top-color: var(--at-c-border);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiMenu, mdiClose, mdiGithub } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { repoUrl } = useGithubRepo();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
const menuOpen = ref(false);
|
||||
|
||||
const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`;
|
||||
const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/'));
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ id: 'screenshots', label: t('nav.screenshots') },
|
||||
{ id: 'download', label: t('nav.download') },
|
||||
{ id: 'comparison', label: t('nav.comparison') },
|
||||
{ id: 'pricing', label: t('nav.pricing') },
|
||||
{ id: 'faq', label: t('nav.faq') },
|
||||
{ href: '#screenshots', label: t('nav.screenshots') },
|
||||
{ href: '#download', label: t('nav.download') },
|
||||
{ href: '#comparison', label: t('nav.comparison') },
|
||||
{ href: '#pricing', label: t('nav.pricing') },
|
||||
{ href: docsHref.value, label: t('footer.links.docs') },
|
||||
{ href: '#faq', label: t('nav.faq') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
|
@ -19,7 +24,7 @@ const navItems = computed(() => [
|
|||
<v-container class="app-header__inner">
|
||||
<AppLogo />
|
||||
<nav class="app-header__nav">
|
||||
<v-btn v-for="item in navItems" :key="item.id" variant="text" :href="`#${item.id}`">
|
||||
<v-btn v-for="item in navItems" :key="item.href" variant="text" :href="item.href">
|
||||
{{ item.label }}
|
||||
</v-btn>
|
||||
</nav>
|
||||
|
|
@ -49,12 +54,12 @@ const navItems = computed(() => [
|
|||
<div style="flex: 1" />
|
||||
<v-btn :icon="mdiClose" variant="text" @click="menuOpen = false" />
|
||||
</div>
|
||||
<hr class="mobile-menu__divider" />
|
||||
<hr class="mobile-menu__divider">
|
||||
<nav class="mobile-menu__list">
|
||||
<a
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
:href="`#${item.id}`"
|
||||
:key="item.href"
|
||||
:href="item.href"
|
||||
class="mobile-menu__link"
|
||||
@click="menuOpen = false"
|
||||
>
|
||||
|
|
@ -69,7 +74,7 @@ const navItems = computed(() => [
|
|||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
<hr class="mobile-menu__divider" />
|
||||
<hr class="mobile-menu__divider">
|
||||
<div class="mobile-menu__actions">
|
||||
<LanguageSwitcher compact />
|
||||
<ThemeToggle />
|
||||
|
|
@ -89,18 +94,18 @@ const navItems = computed(() => [
|
|||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
z-index: var(--at-z-header);
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(0, 240, 255, 0.08);
|
||||
backdrop-filter: blur(var(--at-blur-md));
|
||||
-webkit-backdrop-filter: blur(var(--at-blur-md));
|
||||
border-bottom: 1px solid var(--at-c-border);
|
||||
}
|
||||
|
||||
.v-theme--light .app-header {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
||||
border-bottom-color: var(--at-c-border);
|
||||
}
|
||||
|
||||
.v-theme--dark .app-header {
|
||||
|
|
@ -136,15 +141,15 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
border-color: rgba(0, 240, 255, 0.25) !important;
|
||||
color: #00f0ff !important;
|
||||
border-color: var(--at-c-border-strong) !important;
|
||||
color: var(--at-c-cyan) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 12px !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
}
|
||||
|
||||
.app-header__github-btn:hover {
|
||||
border-color: rgba(0, 240, 255, 0.5) !important;
|
||||
border-color: var(--at-c-focus) !important;
|
||||
background: rgba(0, 240, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ export default defineNuxtConfig({
|
|||
nitro: {
|
||||
compressPublicAssets: true,
|
||||
prerender: {
|
||||
ignore: [
|
||||
"/docs",
|
||||
"/docs/**"
|
||||
],
|
||||
routes: [
|
||||
...generateI18nRoutes(),
|
||||
"/sitemap.xml",
|
||||
|
|
|
|||
2011
landing/package-lock.json
generated
2011
landing/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,12 @@
|
|||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"generate:docs": "vitepress build product-docs --outDir .output/public/docs",
|
||||
"generate:all": "nuxt generate && vitepress build product-docs --outDir .output/public/docs",
|
||||
"preview": "nuxt preview",
|
||||
"docs:dev": "vitepress dev product-docs",
|
||||
"docs:build": "vitepress build product-docs",
|
||||
"docs:preview": "vitepress preview product-docs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --check",
|
||||
|
|
@ -29,9 +34,14 @@
|
|||
"devDependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@nuxt/eslint": "^1.12.1",
|
||||
"@shikijs/transformers": "3.22.0",
|
||||
"eslint": "^9.39.2",
|
||||
"medium-zoom": "^1.1.0",
|
||||
"prettier": "^3.8.0",
|
||||
"sass": "^1.97.2",
|
||||
"vite-plugin-vuetify": "^2.1.3"
|
||||
"vite-plugin-vuetify": "^2.1.3",
|
||||
"vitepress": "2.0.0-alpha.17",
|
||||
"vitepress-codeblock-collapse": "^1.0.0",
|
||||
"vitepress-plugin-llms": "^1.12.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@ import "vuetify/styles";
|
|||
import { createVuetify } from "vuetify";
|
||||
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
|
||||
|
||||
const brand = {
|
||||
cyan: "#00f0ff",
|
||||
magenta: "#ff00ff",
|
||||
lightBackground: "#f0f2f5",
|
||||
lightSurface: "#ffffff",
|
||||
darkBackground: "#0a0a0f",
|
||||
darkSurface: "#12121a"
|
||||
};
|
||||
|
||||
export default defineNuxtPlugin({
|
||||
name: "vuetify",
|
||||
setup(nuxtApp) {
|
||||
|
|
@ -16,18 +25,18 @@ export default defineNuxtPlugin({
|
|||
themes: {
|
||||
light: {
|
||||
colors: {
|
||||
primary: "#00f0ff",
|
||||
secondary: "#ff00ff",
|
||||
background: "#f0f2f5",
|
||||
surface: "#ffffff"
|
||||
primary: brand.cyan,
|
||||
secondary: brand.magenta,
|
||||
background: brand.lightBackground,
|
||||
surface: brand.lightSurface
|
||||
}
|
||||
},
|
||||
dark: {
|
||||
colors: {
|
||||
primary: "#00f0ff",
|
||||
secondary: "#ff00ff",
|
||||
background: "#0a0a0f",
|
||||
surface: "#12121a"
|
||||
primary: brand.cyan,
|
||||
secondary: brand.magenta,
|
||||
background: brand.darkBackground,
|
||||
surface: brand.darkSurface
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
212
landing/product-docs/.vitepress/config.ts
Normal file
212
landing/product-docs/.vitepress/config.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import {
|
||||
transformerNotationDiff,
|
||||
transformerNotationErrorLevel,
|
||||
transformerNotationFocus,
|
||||
transformerNotationHighlight
|
||||
} from "@shikijs/transformers";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, type DefaultTheme } from "vitepress";
|
||||
import llmstxt, { copyOrDownloadAsMarkdownButtons } from "vitepress-plugin-llms";
|
||||
|
||||
const REPO = "777genius/claude_agent_teams_ui";
|
||||
const SITE_TITLE = "Agent Teams Docs";
|
||||
const SITE_DESCRIPTION = "Documentation for Agent Teams, a local desktop app for AI agent orchestration.";
|
||||
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
||||
const normalizeBase = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === "/") return "/";
|
||||
return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
|
||||
};
|
||||
const withTrailingSlash = (value: string) => `${trimTrailingSlash(value)}/`;
|
||||
|
||||
const appBase = normalizeBase(process.env.NUXT_APP_BASE_URL || "/");
|
||||
const base = appBase === "/" ? "/docs/" : `${appBase}docs/`;
|
||||
const siteUrl = trimTrailingSlash(
|
||||
process.env.NUXT_PUBLIC_SITE_URL || "https://777genius.github.io/claude_agent_teams_ui"
|
||||
);
|
||||
const publicBaseUrl =
|
||||
appBase === "/" || siteUrl.endsWith(trimTrailingSlash(appBase))
|
||||
? withTrailingSlash(siteUrl)
|
||||
: `${withTrailingSlash(siteUrl)}${appBase.replace(/^\/+/, "")}`;
|
||||
const docsUrl = `${publicBaseUrl}docs/`;
|
||||
const downloadUrl = `${publicBaseUrl}download/`;
|
||||
const ruDownloadUrl = `${publicBaseUrl}ru/download/`;
|
||||
const landingPublicDir = fileURLToPath(new URL("../../public", import.meta.url));
|
||||
|
||||
const rootGuide: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: "Start",
|
||||
items: [
|
||||
{ text: "Quickstart", link: "/guide/quickstart" },
|
||||
{ text: "Installation", link: "/guide/installation" },
|
||||
{ text: "Create a team", link: "/guide/create-team" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Workflows",
|
||||
items: [
|
||||
{ text: "Runtime setup", link: "/guide/runtime-setup" },
|
||||
{ text: "Agent workflow", link: "/guide/agent-workflow" },
|
||||
{ text: "Code review", link: "/guide/code-review" },
|
||||
{ text: "Troubleshooting", link: "/guide/troubleshooting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Reference",
|
||||
items: [
|
||||
{ text: "Concepts", link: "/reference/concepts" },
|
||||
{ text: "Providers and runtimes", link: "/reference/providers-runtimes" },
|
||||
{ text: "Privacy and local data", link: "/reference/privacy-local-data" },
|
||||
{ text: "FAQ", link: "/reference/faq" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const ruGuide: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: "Старт",
|
||||
items: [
|
||||
{ text: "Быстрый старт", link: "/ru/guide/quickstart" },
|
||||
{ text: "Установка", link: "/ru/guide/installation" },
|
||||
{ text: "Создание команды", link: "/ru/guide/create-team" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Рабочие процессы",
|
||||
items: [
|
||||
{ text: "Настройка рантайма", link: "/ru/guide/runtime-setup" },
|
||||
{ text: "Работа агентов", link: "/ru/guide/agent-workflow" },
|
||||
{ text: "Код-ревью", link: "/ru/guide/code-review" },
|
||||
{ text: "Диагностика", link: "/ru/guide/troubleshooting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Справочник",
|
||||
items: [
|
||||
{ text: "Концепции", link: "/ru/reference/concepts" },
|
||||
{ text: "Провайдеры и рантаймы", link: "/ru/reference/providers-runtimes" },
|
||||
{ text: "Приватность и локальные данные", link: "/ru/reference/privacy-local-data" },
|
||||
{ text: "FAQ", link: "/ru/reference/faq" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const rootNav: DefaultTheme.NavItem[] = [
|
||||
{ text: "Guide", link: "/guide/quickstart" },
|
||||
{ text: "Reference", link: "/reference/concepts" },
|
||||
{ text: "Troubleshooting", link: "/guide/troubleshooting" },
|
||||
{ text: "Download", link: downloadUrl, target: "_self" }
|
||||
];
|
||||
|
||||
const ruNav: DefaultTheme.NavItem[] = [
|
||||
{ text: "Руководство", link: "/ru/guide/quickstart" },
|
||||
{ text: "Справочник", link: "/ru/reference/concepts" },
|
||||
{ text: "Диагностика", link: "/ru/guide/troubleshooting" },
|
||||
{ text: "Скачать", link: ruDownloadUrl, target: "_self" }
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
lang: "en-US",
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
base,
|
||||
cleanUrls: true,
|
||||
lastUpdated: true,
|
||||
sitemap: {
|
||||
hostname: docsUrl
|
||||
},
|
||||
head: [
|
||||
["link", { rel: "icon", type: "image/png", href: `${base}logo-192.png` }],
|
||||
["meta", { name: "theme-color", content: "#00f0ff" }],
|
||||
["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" }]
|
||||
],
|
||||
vite: {
|
||||
publicDir: landingPublicDir,
|
||||
plugins: [llmstxt()],
|
||||
optimizeDeps: {
|
||||
include: ["medium-zoom", "vitepress-codeblock-collapse"]
|
||||
}
|
||||
},
|
||||
markdown: {
|
||||
codeTransformers: [
|
||||
transformerNotationDiff(),
|
||||
transformerNotationFocus(),
|
||||
transformerNotationHighlight(),
|
||||
transformerNotationErrorLevel()
|
||||
],
|
||||
config(md) {
|
||||
md.use(copyOrDownloadAsMarkdownButtons);
|
||||
}
|
||||
},
|
||||
themeConfig: {
|
||||
logo: "/logo-192.png",
|
||||
siteTitle: "Agent Teams",
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: "On this page"
|
||||
},
|
||||
search: {
|
||||
provider: "local"
|
||||
},
|
||||
nav: rootNav,
|
||||
sidebar: {
|
||||
"/ru/": ruGuide,
|
||||
"/": rootGuide
|
||||
},
|
||||
socialLinks: [{ icon: "github", link: `https://github.com/${REPO}` }],
|
||||
editLink: {
|
||||
pattern: `https://github.com/${REPO}/edit/main/landing/product-docs/:path`,
|
||||
text: "Edit this page on GitHub"
|
||||
},
|
||||
footer: {
|
||||
message: "Free and open source.",
|
||||
copyright: "Copyright © 777genius"
|
||||
}
|
||||
},
|
||||
locales: {
|
||||
root: {
|
||||
label: "English",
|
||||
lang: "en-US",
|
||||
themeConfig: {
|
||||
nav: rootNav,
|
||||
sidebar: rootGuide,
|
||||
docFooter: {
|
||||
prev: "Previous",
|
||||
next: "Next"
|
||||
}
|
||||
}
|
||||
},
|
||||
ru: {
|
||||
label: "Русский",
|
||||
lang: "ru-RU",
|
||||
title: "Документация Agent Teams",
|
||||
description: "Документация Agent Teams, локального desktop-приложения для оркестрации AI-агентов.",
|
||||
themeConfig: {
|
||||
nav: ruNav,
|
||||
sidebar: ruGuide,
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: "На этой странице"
|
||||
},
|
||||
editLink: {
|
||||
pattern: `https://github.com/${REPO}/edit/main/landing/product-docs/:path`,
|
||||
text: "Редактировать на GitHub"
|
||||
},
|
||||
docFooter: {
|
||||
prev: "Назад",
|
||||
next: "Дальше"
|
||||
},
|
||||
footer: {
|
||||
message: "Бесплатно и с открытым кодом.",
|
||||
copyright: "Copyright © 777genius"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
117
landing/product-docs/.vitepress/theme/DocsCardGrid.vue
Normal file
117
landing/product-docs/.vitepress/theme/DocsCardGrid.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<script setup lang="ts">
|
||||
import { useData, withBase } from "vitepress";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{ type?: "start" | "reference" }>(), {
|
||||
type: "start"
|
||||
});
|
||||
|
||||
const { page } = useData();
|
||||
const isRu = computed(() => page.value.relativePath.startsWith("ru/"));
|
||||
|
||||
const cards = computed(() => {
|
||||
if (isRu.value) {
|
||||
return props.type === "reference"
|
||||
? [
|
||||
{ icon: "◈", title: "Концепции", desc: "Команды, задачи, роли и уровни автономности.", link: "/ru/reference/concepts" },
|
||||
{ icon: "⌁", title: "Рантаймы", desc: "Claude, Codex, OpenCode и multimodel-режим.", link: "/ru/reference/providers-runtimes" },
|
||||
{ icon: "⌘", title: "Локальные данные", desc: "Что хранится на машине и что уходит провайдерам.", link: "/ru/reference/privacy-local-data" },
|
||||
{ icon: "?", title: "FAQ", desc: "Короткие ответы на частые вопросы.", link: "/ru/reference/faq" }
|
||||
]
|
||||
: [
|
||||
{ icon: "01", title: "Быстрый старт", desc: "Поставить приложение и создать первую команду.", link: "/ru/guide/quickstart" },
|
||||
{ icon: "02", title: "Установка", desc: "Платформы, релизы и запуск из исходников.", link: "/ru/guide/installation" },
|
||||
{ icon: "03", title: "Создание команды", desc: "Роли, lead prompt и границы работы.", link: "/ru/guide/create-team" },
|
||||
{ icon: "04", title: "Код-ревью", desc: "Проверка изменений по задачам и hunk-level decisions.", link: "/ru/guide/code-review" }
|
||||
];
|
||||
}
|
||||
|
||||
return props.type === "reference"
|
||||
? [
|
||||
{ icon: "◈", title: "Concepts", desc: "Teams, tasks, roles, and autonomy levels.", link: "/reference/concepts" },
|
||||
{ icon: "⌁", title: "Runtimes", desc: "Claude, Codex, OpenCode, and multimodel mode.", link: "/reference/providers-runtimes" },
|
||||
{ icon: "⌘", title: "Local data", desc: "What stays on disk and what providers receive.", link: "/reference/privacy-local-data" },
|
||||
{ icon: "?", title: "FAQ", desc: "Short answers to common questions.", link: "/reference/faq" }
|
||||
]
|
||||
: [
|
||||
{ icon: "01", title: "Quickstart", desc: "Install the app and create your first team.", link: "/guide/quickstart" },
|
||||
{ icon: "02", title: "Installation", desc: "Platforms, releases, and running from source.", link: "/guide/installation" },
|
||||
{ icon: "03", title: "Create a team", desc: "Roles, lead prompt, and task boundaries.", link: "/guide/create-team" },
|
||||
{ icon: "04", title: "Code review", desc: "Review task changes with hunk-level decisions.", link: "/guide/code-review" }
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="docs-card-grid">
|
||||
<a v-for="card in cards" :key="card.link" class="docs-card" :href="withBase(card.link)">
|
||||
<span class="docs-card__icon">{{ card.icon }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
<span>{{ card.desc }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.docs-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.docs-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 12px;
|
||||
row-gap: 4px;
|
||||
padding: 18px;
|
||||
border: var(--at-glass-border);
|
||||
border-radius: var(--at-radius-xl);
|
||||
background: var(--at-c-surface-soft);
|
||||
color: var(--at-c-text);
|
||||
text-decoration: none !important;
|
||||
transition:
|
||||
border-color var(--at-transition-base),
|
||||
background-color var(--at-transition-base),
|
||||
transform var(--at-transition-base);
|
||||
}
|
||||
|
||||
.docs-card:hover {
|
||||
border-color: var(--at-c-border-strong);
|
||||
background: var(--at-glass-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.docs-card__icon {
|
||||
grid-row: 1 / -1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--at-radius-md);
|
||||
background: var(--at-gradient-panel);
|
||||
color: var(--at-c-cyan);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 13px;
|
||||
border: 1px solid rgba(0, 240, 255, 0.14);
|
||||
}
|
||||
|
||||
.docs-card strong {
|
||||
color: var(--at-c-text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.docs-card span:last-child {
|
||||
color: var(--at-c-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.docs-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
80
landing/product-docs/.vitepress/theme/DocsHeroVisual.vue
Normal file
80
landing/product-docs/.vitepress/theme/DocsHeroVisual.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<script setup lang="ts">
|
||||
import { withBase } from "vitepress";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="docs-hero-visual">
|
||||
<video
|
||||
class="docs-hero-visual__video no-zoom"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:poster="withBase('/screenshots/1.jpg')"
|
||||
>
|
||||
<source :src="withBase('/video/demo.mp4')" type="video/mp4">
|
||||
</video>
|
||||
<div class="docs-hero-visual__wash" />
|
||||
<div class="docs-hero-visual__edge" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.docs-hero-visual {
|
||||
position: absolute;
|
||||
inset: -130px -120px -110px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.docs-hero-visual__video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(14px) saturate(1.32) contrast(1.14);
|
||||
opacity: 0.62;
|
||||
mix-blend-mode: multiply;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.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) 84%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 24%, transparent) 64%, color-mix(in srgb, var(--vp-c-bg) 50%, transparent) 100%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 52%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 64%, transparent) 58%, var(--vp-c-bg) 96%);
|
||||
}
|
||||
|
||||
.docs-hero-visual__edge {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 42%;
|
||||
background: linear-gradient(180deg, transparent, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.dark .docs-hero-visual__video {
|
||||
opacity: 0.52;
|
||||
filter: blur(16px) saturate(1.38) contrast(1.14);
|
||||
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) 78%, transparent) 34%, color-mix(in srgb, var(--vp-c-bg) 26%, transparent) 64%, color-mix(in srgb, var(--vp-c-bg) 58%, transparent) 100%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--vp-c-bg) 50%, transparent) 0%, color-mix(in srgb, var(--vp-c-bg) 68%, transparent) 58%, var(--vp-c-bg) 96%);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.docs-hero-visual {
|
||||
inset: -90px -72px -80px;
|
||||
}
|
||||
|
||||
.docs-hero-visual__video {
|
||||
opacity: 0.16;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
landing/product-docs/.vitepress/theme/DocsLayout.vue
Normal file
89
landing/product-docs/.vitepress/theme/DocsLayout.vue
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<script setup lang="ts">
|
||||
import mediumZoom, { type Zoom } from "medium-zoom";
|
||||
import { useData } from "vitepress";
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import { useCodeblockCollapse } from "vitepress-codeblock-collapse";
|
||||
import "vitepress-codeblock-collapse/style.css";
|
||||
import { computed, nextTick, onMounted, onUnmounted, provide, watch } from "vue";
|
||||
import DocsHeroVisual from "./DocsHeroVisual.vue";
|
||||
|
||||
const { Layout } = DefaultTheme;
|
||||
const { isDark, page } = useData();
|
||||
|
||||
const pagePath = computed(() => page.value.relativePath);
|
||||
useCodeblockCollapse(pagePath);
|
||||
|
||||
let zoom: Zoom | null = null;
|
||||
|
||||
type ViewTransitionHandle = {
|
||||
ready: Promise<void>;
|
||||
};
|
||||
|
||||
type ViewTransitionDocument = Document & {
|
||||
startViewTransition?: (callback: () => Promise<void>) => ViewTransitionHandle;
|
||||
};
|
||||
|
||||
const refreshImageZoom = async () => {
|
||||
await nextTick();
|
||||
zoom?.detach();
|
||||
zoom = mediumZoom(".vp-doc img:not(.no-zoom), .docs-zoom-image", {
|
||||
background: isDark.value ? "rgba(10, 10, 15, 0.94)" : "rgba(248, 250, 252, 0.94)",
|
||||
margin: 24,
|
||||
scrollOffset: 0
|
||||
});
|
||||
};
|
||||
|
||||
const enableTransitions = () =>
|
||||
typeof document !== "undefined" &&
|
||||
"startViewTransition" in document &&
|
||||
window.matchMedia("(prefers-reduced-motion: no-preference)").matches;
|
||||
|
||||
provide("toggle-appearance", async ({ clientX: x, clientY: y }: MouseEvent) => {
|
||||
if (!enableTransitions()) {
|
||||
isDark.value = !isDark.value;
|
||||
return;
|
||||
}
|
||||
|
||||
const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
|
||||
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`];
|
||||
|
||||
const transitionDocument = document as ViewTransitionDocument;
|
||||
const transition = transitionDocument.startViewTransition?.(async () => {
|
||||
isDark.value = !isDark.value;
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
if (!transition) return;
|
||||
|
||||
await transition.ready;
|
||||
|
||||
document.documentElement.animate(
|
||||
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
|
||||
{
|
||||
duration: 300,
|
||||
easing: "ease-in",
|
||||
pseudoElement: `::view-transition-${isDark.value ? "old" : "new"}(root)`
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void refreshImageZoom();
|
||||
});
|
||||
|
||||
watch([pagePath, isDark], () => {
|
||||
void refreshImageZoom();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
zoom?.detach();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<template #home-hero-image>
|
||||
<DocsHeroVisual />
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
76
landing/product-docs/.vitepress/theme/InstallBlock.vue
Normal file
76
landing/product-docs/.vitepress/theme/InstallBlock.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
command?: string;
|
||||
label?: string;
|
||||
copiedLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
command: "git clone https://github.com/777genius/claude_agent_teams_ui.git",
|
||||
label: "Click to copy",
|
||||
copiedLabel: "Copied"
|
||||
}
|
||||
);
|
||||
|
||||
const copied = ref(false);
|
||||
const copyLabel = computed(() => (copied.value ? props.copiedLabel : props.label));
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(props.command);
|
||||
copied.value = true;
|
||||
window.setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 1800);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="install-block" type="button" @click="copy">
|
||||
<code>$ {{ command }}</code>
|
||||
<span>{{ copyLabel }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.install-block {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
gap: 12px;
|
||||
margin: 12px 0 4px;
|
||||
padding: 12px 16px;
|
||||
border: var(--at-glass-border);
|
||||
border-radius: var(--at-radius-lg);
|
||||
background: var(--at-c-surface-soft);
|
||||
color: var(--at-c-text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--at-transition-base),
|
||||
background-color var(--at-transition-base),
|
||||
transform var(--at-transition-base);
|
||||
}
|
||||
|
||||
.install-block:hover {
|
||||
border-color: var(--at-c-border-strong);
|
||||
background: var(--at-glass-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.install-block code {
|
||||
overflow: hidden;
|
||||
color: var(--at-c-text);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.install-block span {
|
||||
flex-shrink: 0;
|
||||
color: var(--at-c-cyan);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
39
landing/product-docs/.vitepress/theme/ZoomImage.vue
Normal file
39
landing/product-docs/.vitepress/theme/ZoomImage.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { withBase } from "vitepress";
|
||||
|
||||
defineProps<{
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure class="zoom-image">
|
||||
<img class="docs-zoom-image" :src="withBase(src)" :alt="alt" loading="lazy" decoding="async">
|
||||
<figcaption v-if="caption">{{ caption }}</figcaption>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.zoom-image {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.zoom-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: var(--at-glass-border);
|
||||
border-radius: var(--at-radius-xl);
|
||||
background: var(--at-c-dark-1);
|
||||
box-shadow: var(--at-shadow-card);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.zoom-image figcaption {
|
||||
margin-top: 8px;
|
||||
color: var(--at-c-text-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
400
landing/product-docs/.vitepress/theme/custom.css
Normal file
400
landing/product-docs/.vitepress/theme/custom.css
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
:root {
|
||||
--vp-font-family-base: var(--at-font-sans);
|
||||
--vp-font-family-mono: var(--at-font-mono);
|
||||
--vp-c-brand-1: var(--at-c-cyan);
|
||||
--vp-c-brand-2: var(--at-c-cyan-strong);
|
||||
--vp-c-brand-3: var(--at-c-cyan-deep);
|
||||
--vp-c-brand-soft: rgba(0, 240, 255, 0.12);
|
||||
--vp-c-bg: var(--at-c-light-2);
|
||||
--vp-c-bg-alt: var(--at-c-light-1);
|
||||
--vp-c-bg-elv: var(--at-c-light-0);
|
||||
--vp-c-bg-soft: rgba(255, 255, 255, 0.74);
|
||||
--vp-c-text-1: var(--at-c-text-light-1);
|
||||
--vp-c-text-2: var(--at-c-text-light-2);
|
||||
--vp-c-text-3: var(--at-c-text-light-3);
|
||||
--vp-c-divider: rgba(0, 0, 0, 0.08);
|
||||
--vp-c-border: rgba(0, 0, 0, 0.08);
|
||||
--vp-nav-bg-color: rgba(255, 255, 255, 0.82);
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: linear-gradient(135deg, #111827 0%, #047f94 54%, #7c2d8f 100%);
|
||||
--vp-button-brand-bg: var(--at-c-cyan);
|
||||
--vp-button-brand-text: var(--at-c-dark-1);
|
||||
--vp-button-brand-hover-bg: var(--at-c-green);
|
||||
--vp-button-alt-bg: rgba(255, 255, 255, 0.72);
|
||||
--vp-button-alt-hover-bg: rgba(255, 255, 255, 0.92);
|
||||
--vp-code-bg: rgba(8, 145, 178, 0.08);
|
||||
--vp-code-color: var(--at-c-cyan-deep);
|
||||
--vp-code-block-bg: #0a0a0f;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-c-bg: var(--at-c-dark-1);
|
||||
--vp-c-bg-alt: var(--at-c-dark-2);
|
||||
--vp-c-bg-elv: rgba(18, 18, 26, 0.96);
|
||||
--vp-c-bg-soft: rgba(10, 10, 15, 0.72);
|
||||
--vp-c-text-1: var(--at-c-text-dark-1);
|
||||
--vp-c-text-2: var(--at-c-text-dark-2);
|
||||
--vp-c-text-3: var(--at-c-text-dark-muted);
|
||||
--vp-c-divider: rgba(0, 240, 255, 0.1);
|
||||
--vp-c-border: rgba(0, 240, 255, 0.12);
|
||||
--vp-nav-bg-color: rgba(10, 10, 15, 0.82);
|
||||
--vp-home-hero-name-background: var(--at-gradient-brand-text);
|
||||
--vp-button-alt-bg: rgba(0, 240, 255, 0.08);
|
||||
--vp-button-alt-hover-bg: rgba(0, 240, 255, 0.14);
|
||||
--vp-code-bg: rgba(0, 240, 255, 0.1);
|
||||
--vp-code-color: var(--at-c-cyan);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.Layout {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.Layout::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
background:
|
||||
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);
|
||||
}
|
||||
|
||||
.Layout::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.VPNavBar {
|
||||
border-bottom: 1px solid var(--vp-c-divider) !important;
|
||||
background: var(--vp-nav-bg-color) !important;
|
||||
backdrop-filter: blur(var(--at-blur-md)) !important;
|
||||
-webkit-backdrop-filter: blur(var(--at-blur-md)) !important;
|
||||
}
|
||||
|
||||
.VPNavBarTitle .logo {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--at-radius-sm);
|
||||
}
|
||||
|
||||
.VPNavBarTitle .title {
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.VPHero .name.clip {
|
||||
background: var(--vp-home-hero-name-background) !important;
|
||||
-webkit-background-clip: text !important;
|
||||
background-clip: text !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
}
|
||||
|
||||
.VPHero .text {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.VPHero .tagline {
|
||||
max-width: 680px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.VPHero .actions {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.VPHero.has-image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 560px;
|
||||
padding: 96px 24px 76px;
|
||||
}
|
||||
|
||||
.VPHero.has-image .container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block !important;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.VPHero.has-image .main {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 820px !important;
|
||||
padding: 38px 0;
|
||||
}
|
||||
|
||||
.VPHero.has-image .image {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
z-index: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.VPHero.has-image .image-container {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.VPHero.has-image .image-bg {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.VPButton {
|
||||
border-radius: var(--at-radius-lg) !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
min-width: 118px;
|
||||
min-height: 42px;
|
||||
padding: 0 18px !important;
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 13px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1 !important;
|
||||
border: 1px solid transparent !important;
|
||||
transition:
|
||||
transform var(--at-transition-base),
|
||||
border-color var(--at-transition-base),
|
||||
box-shadow var(--at-transition-base),
|
||||
background-color var(--at-transition-base) !important;
|
||||
}
|
||||
|
||||
.VPButton:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.VPButton.brand {
|
||||
background: var(--at-gradient-brand) !important;
|
||||
color: #061018 !important;
|
||||
border-color: rgba(0, 240, 255, 0.38) !important;
|
||||
box-shadow: var(--at-shadow-cyan-sm);
|
||||
}
|
||||
|
||||
.VPButton.alt {
|
||||
color: var(--vp-c-text-1) !important;
|
||||
border-color: var(--vp-c-border) !important;
|
||||
box-shadow: 0 12px 28px -22px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.VPButton.brand:hover,
|
||||
.VPButton.alt:hover {
|
||||
border-color: var(--at-c-border-strong) !important;
|
||||
}
|
||||
|
||||
.VPFeature {
|
||||
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));
|
||||
transition:
|
||||
transform var(--at-transition-base),
|
||||
border-color var(--at-transition-base),
|
||||
box-shadow var(--at-transition-base) !important;
|
||||
}
|
||||
|
||||
.VPFeature:hover {
|
||||
border-color: var(--at-c-border-strong) !important;
|
||||
box-shadow: var(--at-shadow-cyan-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.VPFeature .box {
|
||||
display: grid !important;
|
||||
grid-template-columns: 42px 1fr !important;
|
||||
grid-template-rows: auto auto !important;
|
||||
column-gap: 12px !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.VPFeature .box > .icon {
|
||||
grid-row: 1 !important;
|
||||
grid-column: 1 !important;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 0 !important;
|
||||
border-radius: var(--at-radius-md);
|
||||
background: var(--at-gradient-panel);
|
||||
border: 1px solid rgba(0, 240, 255, 0.14);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.VPFeature .box > .title {
|
||||
grid-row: 1 !important;
|
||||
grid-column: 2 !important;
|
||||
}
|
||||
|
||||
.VPFeature .box > .details {
|
||||
grid-row: 2 !important;
|
||||
grid-column: 1 / -1 !important;
|
||||
}
|
||||
|
||||
.VPFeature .box > .link-text {
|
||||
grid-row: 3 !important;
|
||||
grid-column: 1 / -1 !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
|
||||
.VPFeature .box > .link-text .link-text-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.VPSidebar {
|
||||
background: color-mix(in srgb, var(--vp-c-bg) 86%, transparent) !important;
|
||||
backdrop-filter: blur(var(--at-blur-md));
|
||||
}
|
||||
|
||||
.VPSidebarItem .item .text,
|
||||
.VPDocAsideOutline .outline-link {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.vp-doc h1,
|
||||
.vp-doc h2,
|
||||
.vp-doc h3 {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.vp-doc h1 {
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dark .vp-doc h1 {
|
||||
background: var(--at-gradient-brand-text);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.vp-doc p,
|
||||
.vp-doc li {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.vp-doc a {
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.VPNavBarMenuLink[target="_self"].vp-external-link-icon::after,
|
||||
.VPNavScreenMenuLink[target="_self"].vp-external-link-icon::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vp-doc table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border: var(--at-glass-border);
|
||||
border-radius: var(--at-radius-xl);
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.vp-doc th {
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--at-c-cyan);
|
||||
background: rgba(0, 240, 255, 0.05);
|
||||
}
|
||||
|
||||
.vp-doc :not(pre) > code {
|
||||
border: 1px solid rgba(0, 240, 255, 0.1);
|
||||
border-radius: var(--at-radius-xs);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.vp-doc div[class*="language-"] {
|
||||
border: 1px solid rgba(0, 240, 255, 0.12);
|
||||
border-radius: var(--at-radius-xl);
|
||||
box-shadow: var(--at-shadow-card);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block {
|
||||
border-radius: var(--at-radius-xl);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.tip {
|
||||
border-color: rgba(57, 255, 20, 0.22);
|
||||
background: rgba(57, 255, 20, 0.07);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.warning {
|
||||
border-color: rgba(255, 215, 0, 0.26);
|
||||
background: rgba(255, 215, 0, 0.07);
|
||||
}
|
||||
|
||||
.medium-zoom-overlay {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.medium-zoom-image--opened {
|
||||
z-index: 10001;
|
||||
border-radius: var(--at-radius-lg);
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-new(root),
|
||||
.dark::view-transition-old(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.VPHero.has-image {
|
||||
min-height: 520px;
|
||||
padding: 72px 20px 56px;
|
||||
}
|
||||
|
||||
.VPHero.has-image .main {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.VPFeature .box {
|
||||
grid-template-columns: 36px 1fr !important;
|
||||
}
|
||||
|
||||
.VPHero .text {
|
||||
font-size: 34px;
|
||||
}
|
||||
}
|
||||
28
landing/product-docs/.vitepress/theme/index.ts
Normal file
28
landing/product-docs/.vitepress/theme/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Theme } from "vitepress";
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import CopyOrDownloadAsMarkdownButtons from "vitepress-plugin-llms/vitepress-components/CopyOrDownloadAsMarkdownButtons.vue";
|
||||
import DocsCardGrid from "./DocsCardGrid.vue";
|
||||
import DocsHeroVisual from "./DocsHeroVisual.vue";
|
||||
import InstallBlock from "./InstallBlock.vue";
|
||||
import Layout from "./DocsLayout.vue";
|
||||
import ZoomImage from "./ZoomImage.vue";
|
||||
import "../../../assets/styles/brand-tokens.css";
|
||||
import "./custom.css";
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout,
|
||||
enhanceApp({ app }) {
|
||||
app.component("CopyOrDownloadAsMarkdownButtons", CopyOrDownloadAsMarkdownButtons);
|
||||
app.component("DocsCardGrid", DocsCardGrid);
|
||||
app.component("DocsHeroVisual", DocsHeroVisual);
|
||||
app.component("InstallBlock", InstallBlock);
|
||||
app.component("ZoomImage", ZoomImage);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("vite:preloadError", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
} satisfies Theme;
|
||||
35
landing/product-docs/guide/agent-workflow.md
Normal file
35
landing/product-docs/guide/agent-workflow.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Agent Workflow
|
||||
|
||||
Agent Teams makes agent work visible as task state, messages, logs, and reviewable code changes.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Stage | What happens |
|
||||
| --- | --- |
|
||||
| Provisioning | The app starts the team and confirms runtime readiness |
|
||||
| Planning | The lead creates tasks and may assign teammates |
|
||||
| In progress | Agents work in parallel and update task state |
|
||||
| Review | Changes are reviewed by agents or by you |
|
||||
| Done | Accepted work stays linked to its task history |
|
||||
|
||||
## Kanban board
|
||||
|
||||
The board is the primary operating surface. It lets you scan work, spot blocked tasks, open task detail, inspect logs, and review changes without reading raw session files.
|
||||
|
||||
## Messages and comments
|
||||
|
||||
Use direct messages when you need to redirect an agent. Use task comments when the note belongs to a specific piece of work. Comments preserve context for later review.
|
||||
|
||||
## Task logs
|
||||
|
||||
Task-specific logs isolate runtime output, actions, and messages for one assignment. Use them when you need to answer:
|
||||
|
||||
- what did this agent run?
|
||||
- why did it change this file?
|
||||
- did it ask another teammate for help?
|
||||
- which task produced this diff?
|
||||
|
||||
## Live processes
|
||||
|
||||
The live process section shows URLs and running processes when agents start local servers or tools. Open URLs directly from the app to inspect results.
|
||||
|
||||
35
landing/product-docs/guide/code-review.md
Normal file
35
landing/product-docs/guide/code-review.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# 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.
|
||||
|
||||
## Review surface
|
||||
|
||||
Use the review UI to:
|
||||
|
||||
- inspect changed files
|
||||
- accept or reject individual hunks
|
||||
- leave comments
|
||||
- connect the diff back to the task and agent logs
|
||||
|
||||
## Hunk-level decisions
|
||||
|
||||
Accept small correct changes and reject isolated mistakes without throwing away the whole task. This is useful when an agent mostly solved the task but overreached in one file.
|
||||
|
||||
## Agent review workflow
|
||||
|
||||
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.
|
||||
|
||||
## What to check manually
|
||||
|
||||
Prioritize:
|
||||
|
||||
- provider auth and runtime detection
|
||||
- IPC, preload, and filesystem boundaries
|
||||
- Git and worktree behavior
|
||||
- parsing and task lifecycle logic
|
||||
- persistence and code review flows
|
||||
|
||||
## Verification
|
||||
|
||||
Prefer focused verification commands. Broad formatting or lint-fix commands should not be used unless the task explicitly intends broad formatting churn.
|
||||
|
||||
51
landing/product-docs/guide/create-team.md
Normal file
51
landing/product-docs/guide/create-team.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Create a Team
|
||||
|
||||
A team is a named group of agents with roles, a lead, a target project, and a coordination prompt.
|
||||
|
||||
## Recommended first team
|
||||
|
||||
Start with a small team:
|
||||
|
||||
| Role | Purpose |
|
||||
| --- | --- |
|
||||
| Lead | Splits work, creates tasks, coordinates teammates |
|
||||
| Builder | Implements scoped tasks |
|
||||
| Reviewer | Reviews output, catches regressions, asks for fixes |
|
||||
|
||||
This shape gives you enough coordination to see the product value without making the first launch noisy.
|
||||
|
||||
## Write a good team brief
|
||||
|
||||
The team brief should include:
|
||||
|
||||
- the outcome you want
|
||||
- the files or feature areas that matter
|
||||
- risk boundaries, such as "do not refactor unrelated modules"
|
||||
- review expectations
|
||||
- verification commands when you know them
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Build a focused improvement to the download flow. Keep changes inside the landing app unless a shared helper is clearly needed. Create tasks before implementation, review each task diff, and run landing lint/build checks.
|
||||
```
|
||||
|
||||
## Choose autonomy
|
||||
|
||||
Agent Teams supports different levels of control. Use more autonomy for routine changes and tighter review for risky areas like provider auth, IPC, persistence, Git workflows, and release tooling.
|
||||
|
||||
## Add context
|
||||
|
||||
Attach files, screenshots, or specific notes when they materially change the task. Agents can use task descriptions, comments, and attachments as durable context.
|
||||
|
||||
## Watch for task quality
|
||||
|
||||
Good teams create tasks that are:
|
||||
|
||||
- specific enough to review
|
||||
- small enough to finish
|
||||
- linked to visible output
|
||||
- backed by a verification path
|
||||
|
||||
If the lead creates vague tasks, send a direct message asking for smaller, testable tasks.
|
||||
|
||||
45
landing/product-docs/guide/installation.md
Normal file
45
landing/product-docs/guide/installation.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Installation
|
||||
|
||||
Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.
|
||||
|
||||
## Download builds
|
||||
|
||||
Use the latest GitHub release when you want the packaged app:
|
||||
|
||||
- macOS Apple Silicon: `.dmg`
|
||||
- macOS Intel: `.dmg`
|
||||
- Windows: `.exe`
|
||||
- Linux: `.AppImage`, `.deb`, `.rpm`, or `.pacman`
|
||||
|
||||
::: warning Windows SmartScreen
|
||||
Unsigned or newly published open-source apps can trigger SmartScreen. If you trust the release source, choose **More info** and then **Run anyway**.
|
||||
:::
|
||||
|
||||
## Requirements
|
||||
|
||||
The packaged app is designed for zero-setup onboarding. It can guide runtime detection and provider authentication from the UI.
|
||||
|
||||
For source development, use:
|
||||
|
||||
| Tool | Version |
|
||||
| --- | --- |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
|
||||
## Run from source
|
||||
|
||||
<InstallBlock command="git clone https://github.com/777genius/claude_agent_teams_ui.git && cd claude_agent_teams_ui && pnpm install && pnpm dev" />
|
||||
|
||||
```bash
|
||||
git clone https://github.com/777genius/claude_agent_teams_ui.git
|
||||
cd claude_agent_teams_ui
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
If you want the freshest local version, use the repository branch that currently carries active development.
|
||||
|
||||
## Updating
|
||||
|
||||
Use the latest release for packaged builds. If you run from source, pull the branch you use and rerun install when dependencies change.
|
||||
|
||||
50
landing/product-docs/guide/quickstart.md
Normal file
50
landing/product-docs/guide/quickstart.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Quickstart
|
||||
|
||||
This guide gets you from a fresh install to a running team.
|
||||
|
||||
## 1. Install Agent Teams
|
||||
|
||||
Download the latest release for your platform from the landing page or GitHub releases.
|
||||
|
||||
::: tip
|
||||
The app is free and open source. The agent runtime you choose may still require provider access, such as Claude, Codex, OpenCode, or API-key based providers.
|
||||
:::
|
||||
|
||||
## 2. Open or create a project
|
||||
|
||||
Launch the app and select the project directory you want agents to work in. Agent Teams reads local project files and runtime/session state so the UI can show tasks, logs, diffs, and teammate activity.
|
||||
|
||||
## 3. Choose a runtime path
|
||||
|
||||
Use the setup flow to detect available runtimes. A common first setup is:
|
||||
|
||||
| Runtime | Good for |
|
||||
| --- | --- |
|
||||
| Claude | Claude Code users and existing Anthropic access |
|
||||
| Codex | Codex-native workflows and OpenAI access |
|
||||
| OpenCode | Multimodel teams and many provider backends |
|
||||
|
||||
## 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.
|
||||
|
||||
## 5. Give the lead a concrete goal
|
||||
|
||||
Write the goal like you would brief an engineering lead:
|
||||
|
||||
```text
|
||||
Improve the onboarding flow. Split the work into tasks, keep changes small, and ask for review before broad refactors.
|
||||
```
|
||||
|
||||
The lead should create tasks, assign work, and coordinate teammates. You can watch progress on the kanban board and intervene with comments or direct messages.
|
||||
|
||||
## 6. Review results
|
||||
|
||||
Open completed or review-ready tasks, inspect the diff, and accept, reject, or comment on individual changes. Use task logs when you need to understand why an agent made a choice.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Create a team](/guide/create-team)
|
||||
- [Runtime setup](/guide/runtime-setup)
|
||||
- [Code review](/guide/code-review)
|
||||
|
||||
33
landing/product-docs/guide/runtime-setup.md
Normal file
33
landing/product-docs/guide/runtime-setup.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Runtime Setup
|
||||
|
||||
Agent Teams is a coordination layer. The actual model work runs through supported local runtimes and providers.
|
||||
|
||||
## Supported paths
|
||||
|
||||
| Path | Use when |
|
||||
| --- | --- |
|
||||
| Claude | You already use Claude Code or Anthropic-backed workflows |
|
||||
| Codex | You want Codex-native runtime integration |
|
||||
| OpenCode | You want multimodel routing and broad provider coverage |
|
||||
|
||||
The app detects supported runtimes and guides setup from the UI when possible.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Operational advice
|
||||
|
||||
- Keep the first runtime setup simple.
|
||||
- Confirm one team can launch before adding many providers.
|
||||
- Treat auth, provider model names, and runtime PATH issues as setup problems, not team-prompt problems.
|
||||
- If launch hangs, check the troubleshooting page before changing code.
|
||||
|
||||
## When to switch runtime paths
|
||||
|
||||
Switch when the current path is blocked by model availability, rate limits, provider capabilities, or team role needs. Keep the same project and team workflow, but validate one small task after switching.
|
||||
|
||||
40
landing/product-docs/guide/troubleshooting.md
Normal file
40
landing/product-docs/guide/troubleshooting.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Troubleshooting
|
||||
|
||||
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, or provider limits.
|
||||
|
||||
## Team does not launch
|
||||
|
||||
Check:
|
||||
|
||||
- the selected runtime is installed or authenticated
|
||||
- the runtime is available in the environment PATH
|
||||
- the provider has access to the requested model
|
||||
- the project path exists and is readable
|
||||
|
||||
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect launch logs before changing team prompts.
|
||||
|
||||
## Agent replies are missing
|
||||
|
||||
Open task logs and teammate messages. Missing replies often come from runtime delivery, parsing, or task filtering issues. Do not assume the model ignored the message until logs confirm it.
|
||||
|
||||
## Tasks are not linked to changes
|
||||
|
||||
Use task-specific logs and code review links. If a diff appears detached, check whether the task id or task reference was included in the agent output.
|
||||
|
||||
## Rate limits
|
||||
|
||||
If a provider reports a known reset time, Agent Teams can nudge the lead to continue after cooldown. If reset time is unknown, wait or switch provider/runtime path.
|
||||
|
||||
## When to collect evidence
|
||||
|
||||
Collect:
|
||||
|
||||
- task id
|
||||
- team name
|
||||
- runtime path
|
||||
- launch log excerpt
|
||||
- provider/model
|
||||
- exact time window
|
||||
|
||||
This is enough to debug most launch and task lifecycle issues.
|
||||
|
||||
67
landing/product-docs/index.md
Normal file
67
landing/product-docs/index.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
layout: home
|
||||
hero:
|
||||
name: Agent Teams Docs
|
||||
text: Run AI agent teams from a local desktop app
|
||||
tagline: Create teams, watch work move across a kanban board, review code changes, and coordinate Claude, Codex, OpenCode, and multimodel workflows without giving up local control.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Quickstart
|
||||
link: /guide/quickstart
|
||||
- theme: alt
|
||||
text: Install
|
||||
link: /guide/installation
|
||||
- theme: alt
|
||||
text: Concepts
|
||||
link: /reference/concepts
|
||||
features:
|
||||
- icon: "01"
|
||||
title: Team-first workflow
|
||||
details: Define roles, launch a lead, and let agents split, claim, and coordinate tasks.
|
||||
link: /guide/create-team
|
||||
linkText: Create a team
|
||||
- icon: "02"
|
||||
title: Live kanban board
|
||||
details: Watch tasks move through todo, progress, review, blocked, and done as agents work.
|
||||
link: /guide/agent-workflow
|
||||
linkText: Understand workflow
|
||||
- icon: "03"
|
||||
title: Built-in code review
|
||||
details: Inspect task-scoped diffs, accept or reject hunks, and comment where agents need direction.
|
||||
link: /guide/code-review
|
||||
linkText: Review changes
|
||||
- icon: "04"
|
||||
title: Runtime-aware setup
|
||||
details: Use Claude, Codex, OpenCode, or multimodel providers through the access you already have.
|
||||
link: /guide/runtime-setup
|
||||
linkText: Configure runtimes
|
||||
- icon: "05"
|
||||
title: Local-first control
|
||||
details: The desktop app reads local project and runtime state. Your code stays on your machine unless a selected provider receives prompt context.
|
||||
link: /reference/privacy-local-data
|
||||
linkText: Privacy model
|
||||
- icon: "06"
|
||||
title: Debuggable teams
|
||||
details: Trace task logs, runtime output, teammate messages, and live processes when a launch or task gets stuck.
|
||||
link: /guide/troubleshooting
|
||||
linkText: Troubleshoot
|
||||
---
|
||||
|
||||
<InstallBlock />
|
||||
|
||||
## Start here
|
||||
|
||||
Agent Teams is a free desktop app for orchestrating AI agent teams. You are not just sending isolated prompts to one agent: you create a team, assign roles, and watch agents coordinate work through a task board.
|
||||
|
||||
<DocsCardGrid />
|
||||
|
||||
## Reference
|
||||
|
||||
Use the reference pages when you need exact terminology, provider behavior, or privacy boundaries.
|
||||
|
||||
<DocsCardGrid type="reference" />
|
||||
|
||||
## Product preview
|
||||
|
||||
<ZoomImage src="/screenshots/1.jpg" alt="Agent Teams kanban board" caption="Task status, teammate activity, and review workflow stay visible in one workspace." />
|
||||
|
||||
32
landing/product-docs/reference/concepts.md
Normal file
32
landing/product-docs/reference/concepts.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Concepts
|
||||
|
||||
This page defines the core terms used across Agent Teams.
|
||||
|
||||
## Team
|
||||
|
||||
A team is a group of agents configured for a project. A team usually has a lead and one or more teammates with specialized roles.
|
||||
|
||||
## Lead
|
||||
|
||||
The lead coordinates work. It should break goals into tasks, assign teammates, track blockers, and ask for review when needed.
|
||||
|
||||
## Task
|
||||
|
||||
A task is the durable unit of work. It has status, description, comments, logs, attachments, and reviewable changes.
|
||||
|
||||
## Solo mode
|
||||
|
||||
Solo mode runs a one-member team. It is useful for quick work, lower token usage, and validating a prompt before expanding to a full team.
|
||||
|
||||
## Cross-team communication
|
||||
|
||||
Agents can message within and across teams. Use this when separate teams own related work and need to coordinate.
|
||||
|
||||
## Autonomy level
|
||||
|
||||
Autonomy controls how much agents can do before asking. Higher autonomy is faster; lower autonomy is safer for sensitive code paths.
|
||||
|
||||
## Runtime
|
||||
|
||||
A runtime is the local execution path that connects Agent Teams to a model/provider workflow, such as Claude, Codex, or OpenCode.
|
||||
|
||||
29
landing/product-docs/reference/faq.md
Normal file
29
landing/product-docs/reference/faq.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# FAQ
|
||||
|
||||
## Is Agent Teams free?
|
||||
|
||||
Yes. The app is free and open source. Provider or runtime access may still cost money depending on what you use.
|
||||
|
||||
## Do I need to install Claude or Codex first?
|
||||
|
||||
Not always. The app guides runtime detection and setup from the UI. Some paths still require external runtime auth.
|
||||
|
||||
## 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.
|
||||
|
||||
## Can agents talk to each other?
|
||||
|
||||
Yes. Agents can message teammates, comment on tasks, and coordinate across teams.
|
||||
|
||||
## Can I review code before accepting it?
|
||||
|
||||
Yes. The review flow is built around task-scoped diffs and hunk-level decisions.
|
||||
|
||||
## What is solo mode?
|
||||
|
||||
Solo mode is a one-agent team. It is useful for smaller tasks and lower coordination overhead.
|
||||
|
||||
## What should I do when a launch hangs?
|
||||
|
||||
Open troubleshooting, collect runtime logs, and verify provider auth before changing prompts.
|
||||
30
landing/product-docs/reference/privacy-local-data.md
Normal file
30
landing/product-docs/reference/privacy-local-data.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Privacy and Local Data
|
||||
|
||||
Agent Teams is local-first, but the selected provider path still matters.
|
||||
|
||||
## What stays local
|
||||
|
||||
The desktop app runs on your machine and reads local project/runtime data to power the UI:
|
||||
|
||||
- project files
|
||||
- task metadata
|
||||
- runtime/session logs
|
||||
- review state
|
||||
- local app settings
|
||||
|
||||
## What can leave your machine
|
||||
|
||||
When an agent asks a provider-backed model to work, prompt context and tool results may be sent through that provider/runtime path. This depends on the runtime and provider you choose.
|
||||
|
||||
## Practical guidance
|
||||
|
||||
- Do not attach secrets to tasks.
|
||||
- Review provider policies for sensitive projects.
|
||||
- Use lower autonomy for risky repositories.
|
||||
- Keep task scope narrow when working with private code.
|
||||
- Prefer local evidence and logs when debugging.
|
||||
|
||||
## Open source model
|
||||
|
||||
The app itself is open source and free. You can inspect how local orchestration, task tracking, and review flows work in the repository.
|
||||
|
||||
40
landing/product-docs/reference/providers-runtimes.md
Normal file
40
landing/product-docs/reference/providers-runtimes.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Providers and Runtimes
|
||||
|
||||
Agent Teams separates orchestration from model access.
|
||||
|
||||
## What the app provides
|
||||
|
||||
Agent Teams provides:
|
||||
|
||||
- team and task orchestration
|
||||
- kanban board UI
|
||||
- teammate messaging
|
||||
- task logs
|
||||
- review UI
|
||||
- local project integration
|
||||
|
||||
## What the runtime provides
|
||||
|
||||
The runtime provides:
|
||||
|
||||
- model execution
|
||||
- provider authentication
|
||||
- tool execution behavior
|
||||
- model-specific rate limits and capabilities
|
||||
|
||||
## Common choices
|
||||
|
||||
| Runtime | Notes |
|
||||
| --- | --- |
|
||||
| Claude | Good for Claude Code users and Anthropic access |
|
||||
| Codex | Good for Codex-native workflows and OpenAI access |
|
||||
| OpenCode | Good for multimodel routing and broad provider coverage |
|
||||
|
||||
## Provider costs
|
||||
|
||||
Agent Teams is free. Provider usage is governed by the runtime/provider you select.
|
||||
|
||||
## Capability checks
|
||||
|
||||
During setup, the app may perform access and capability checks. This helps detect missing runtime auth before a team launch fails halfway through provisioning.
|
||||
|
||||
35
landing/product-docs/ru/guide/agent-workflow.md
Normal file
35
landing/product-docs/ru/guide/agent-workflow.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Работа агентов
|
||||
|
||||
Agent Teams делает работу агентов видимой через task state, messages, logs и reviewable code changes.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Этап | Что происходит |
|
||||
| --- | --- |
|
||||
| Provisioning | Приложение запускает команду и проверяет готовность runtime |
|
||||
| Planning | Lead создаёт задачи и назначает teammates |
|
||||
| In progress | Агенты работают параллельно и обновляют статус задач |
|
||||
| Review | Изменения проверяют агенты или вы |
|
||||
| Done | Принятая работа остаётся связанной с историей задачи |
|
||||
|
||||
## Канбан-доска
|
||||
|
||||
Доска - основной рабочий экран. Через неё удобно смотреть работу, находить blocked tasks, открывать task detail, читать logs и ревьюить changes без ручного чтения session files.
|
||||
|
||||
## Messages и comments
|
||||
|
||||
Direct messages подходят для перенаправления агента. Task comments лучше использовать, когда заметка относится к конкретной работе. Комментарии сохраняют контекст для review.
|
||||
|
||||
## Task logs
|
||||
|
||||
Task-specific logs изолируют runtime output, actions и messages по одному assignment. Они помогают понять:
|
||||
|
||||
- что агент запускал?
|
||||
- почему он изменил этот файл?
|
||||
- просил ли он помощи у teammate?
|
||||
- какая задача породила diff?
|
||||
|
||||
## Live processes
|
||||
|
||||
Live process section показывает URLs и running processes, когда агенты поднимают локальные servers или tools. Открывайте URL прямо из приложения.
|
||||
|
||||
35
landing/product-docs/ru/guide/code-review.md
Normal file
35
landing/product-docs/ru/guide/code-review.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Код-ревью
|
||||
|
||||
Code review в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff.
|
||||
|
||||
## Review surface
|
||||
|
||||
Через review UI можно:
|
||||
|
||||
- смотреть changed files
|
||||
- принимать или отклонять отдельные hunks
|
||||
- оставлять comments
|
||||
- связывать diff с task logs и агентом
|
||||
|
||||
## Hunk-level decisions
|
||||
|
||||
Принимайте маленькие правильные изменения и отклоняйте отдельные ошибки без удаления всей работы. Это полезно, когда агент в целом решил задачу, но переборщил в одном файле.
|
||||
|
||||
## Agent review workflow
|
||||
|
||||
Команды могут ревьюить работу друг друга до вашего финального решения. Это ловит очевидные регрессии, но risky areas всё равно стоит проверять вручную.
|
||||
|
||||
## Что проверять вручную
|
||||
|
||||
Приоритет:
|
||||
|
||||
- provider auth и runtime detection
|
||||
- IPC, preload и filesystem boundaries
|
||||
- Git и worktree behavior
|
||||
- parsing и task lifecycle logic
|
||||
- persistence и code review flows
|
||||
|
||||
## Verification
|
||||
|
||||
Лучше запускать focused verification commands. Broad formatting или lint-fix команды не стоит использовать, если задача явно не про форматирование.
|
||||
|
||||
51
landing/product-docs/ru/guide/create-team.md
Normal file
51
landing/product-docs/ru/guide/create-team.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Создание команды
|
||||
|
||||
Команда - это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt.
|
||||
|
||||
## Первая команда
|
||||
|
||||
Начните с малого:
|
||||
|
||||
| Роль | Задача |
|
||||
| --- | --- |
|
||||
| Lead | Делит работу, создаёт задачи, координирует teammates |
|
||||
| Builder | Реализует scoped tasks |
|
||||
| Reviewer | Проверяет результат, ловит регрессии, просит fixes |
|
||||
|
||||
Такая форма даёт достаточно координации, но не создаёт лишний шум на первом запуске.
|
||||
|
||||
## Хороший team brief
|
||||
|
||||
В brief стоит указать:
|
||||
|
||||
- нужный outcome
|
||||
- важные files или feature areas
|
||||
- границы риска, например "не refactor unrelated modules"
|
||||
- ожидания по review
|
||||
- verification commands, если они известны
|
||||
|
||||
Пример:
|
||||
|
||||
```text
|
||||
Улучши download flow. Держи изменения внутри landing app, если shared helper явно не нужен. Создай задачи до реализации, проверь diff каждой задачи и запусти landing lint/build checks.
|
||||
```
|
||||
|
||||
## Уровень автономности
|
||||
|
||||
Agent Teams поддерживает разные уровни контроля. Больше автономности подходит для рутинных изменений, меньше - для provider auth, IPC, persistence, Git workflows и release tooling.
|
||||
|
||||
## Контекст
|
||||
|
||||
Прикладывайте файлы, screenshots или заметки, если они реально меняют задачу. Task descriptions, comments и attachments становятся устойчивым контекстом.
|
||||
|
||||
## Качество задач
|
||||
|
||||
Хорошие задачи:
|
||||
|
||||
- конкретны для review
|
||||
- достаточно малы для завершения
|
||||
- связаны с видимым результатом
|
||||
- имеют verification path
|
||||
|
||||
Если lead создаёт размытые задачи, напишите ему direct message и попросите сделать задачи меньше и проверяемее.
|
||||
|
||||
45
landing/product-docs/ru/guide/installation.md
Normal file
45
landing/product-docs/ru/guide/installation.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Установка
|
||||
|
||||
Agent Teams распространяется как desktop-приложение для macOS, Windows и Linux.
|
||||
|
||||
## Готовые сборки
|
||||
|
||||
Берите последний GitHub release:
|
||||
|
||||
- macOS Apple Silicon: `.dmg`
|
||||
- macOS Intel: `.dmg`
|
||||
- Windows: `.exe`
|
||||
- Linux: `.AppImage`, `.deb`, `.rpm` или `.pacman`
|
||||
|
||||
::: warning Windows SmartScreen
|
||||
Новые open-source приложения могут вызывать SmartScreen. Если вы доверяете источнику релиза, выберите **More info**, затем **Run anyway**.
|
||||
:::
|
||||
|
||||
## Требования
|
||||
|
||||
Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication.
|
||||
|
||||
Для запуска из исходников:
|
||||
|
||||
| Инструмент | Версия |
|
||||
| --- | --- |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
|
||||
## Запуск из исходников
|
||||
|
||||
<InstallBlock command="git clone https://github.com/777genius/claude_agent_teams_ui.git && cd claude_agent_teams_ui && pnpm install && pnpm dev" label="Скопировать" copied-label="Скопировано" />
|
||||
|
||||
```bash
|
||||
git clone https://github.com/777genius/claude_agent_teams_ui.git
|
||||
cd claude_agent_teams_ui
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Если нужна самая свежая локальная версия, используйте ветку репозитория, где сейчас идёт активная разработка.
|
||||
|
||||
## Обновления
|
||||
|
||||
Для packaged builds берите последний release. Для запуска из исходников подтяните нужную ветку и повторите install, если поменялись зависимости.
|
||||
|
||||
50
landing/product-docs/ru/guide/quickstart.md
Normal file
50
landing/product-docs/ru/guide/quickstart.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Быстрый старт
|
||||
|
||||
Этот гайд проводит от свежей установки до первой запущенной команды.
|
||||
|
||||
## 1. Установите Agent Teams
|
||||
|
||||
Скачайте последний релиз под вашу платформу на лендинге или в GitHub releases.
|
||||
|
||||
::: tip
|
||||
Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру, например Claude, Codex, OpenCode или API-key based providers.
|
||||
:::
|
||||
|
||||
## 2. Откройте проект
|
||||
|
||||
Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные project files и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды.
|
||||
|
||||
## 3. Выберите runtime path
|
||||
|
||||
Стандартные варианты:
|
||||
|
||||
| Runtime | Когда подходит |
|
||||
| --- | --- |
|
||||
| Claude | Если вы уже используете Claude Code или Anthropic access |
|
||||
| Codex | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Для multimodel teams и большого числа provider backends |
|
||||
|
||||
## 4. Создайте первую команду
|
||||
|
||||
Начните с маленькой команды: lead, implementation agent и review-oriented agent. Этого достаточно, чтобы проверить workflow без лишнего шума.
|
||||
|
||||
## 5. Дайте lead-агенту конкретную цель
|
||||
|
||||
Пишите задачу как инженерному лиду:
|
||||
|
||||
```text
|
||||
Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими refactor.
|
||||
```
|
||||
|
||||
Lead должен создать задачи, назначить работу и координировать teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages.
|
||||
|
||||
## 6. Проверьте результат
|
||||
|
||||
Откройте задачи в review/done, посмотрите diff, примите или отклоните изменения. Если нужно понять мотивацию агента, откройте task logs.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Создание команды](/ru/guide/create-team)
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup)
|
||||
- [Код-ревью](/ru/guide/code-review)
|
||||
|
||||
33
landing/product-docs/ru/guide/runtime-setup.md
Normal file
33
landing/product-docs/ru/guide/runtime-setup.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Настройка рантайма
|
||||
|
||||
Agent Teams - coordination layer. Model work выполняется через локальные runtimes и providers.
|
||||
|
||||
## Поддерживаемые пути
|
||||
|
||||
| Путь | Когда использовать |
|
||||
| --- | --- |
|
||||
| Claude | Если вы уже используете Claude Code или Anthropic access |
|
||||
| Codex | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Для multimodel routing и широкой provider coverage |
|
||||
|
||||
Приложение по возможности определяет доступные runtimes и ведёт настройку через UI.
|
||||
|
||||
## Provider access
|
||||
|
||||
У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: subscription, local runtime auth или API keys в зависимости от выбранного пути.
|
||||
|
||||
## Multimodel mode
|
||||
|
||||
Multimodel mode может направлять работу через разные provider backends в OpenCode-compatible конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.
|
||||
|
||||
## Практические советы
|
||||
|
||||
- Первый runtime setup держите простым.
|
||||
- Подтвердите запуск одной команды до добавления многих providers.
|
||||
- Auth, model names и PATH issues считайте setup-проблемами, а не проблемами team prompt.
|
||||
- Если запуск завис, сначала откройте диагностику.
|
||||
|
||||
## Когда менять runtime path
|
||||
|
||||
Меняйте путь, когда текущий упирается в availability модели, rate limits, provider capabilities или роли команды. После смены проверьте одну маленькую задачу.
|
||||
|
||||
40
landing/product-docs/ru/guide/troubleshooting.md
Normal file
40
landing/product-docs/ru/guide/troubleshooting.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Диагностика
|
||||
|
||||
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing или provider limits.
|
||||
|
||||
## Команда не запускается
|
||||
|
||||
Проверьте:
|
||||
|
||||
- выбранный runtime установлен или авторизован
|
||||
- runtime доступен в environment PATH
|
||||
- у провайдера есть доступ к нужной модели
|
||||
- project path существует и читается
|
||||
|
||||
Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала смотрите launch logs.
|
||||
|
||||
## Не видны ответы агента
|
||||
|
||||
Откройте task logs и teammate messages. Пропавшие replies часто связаны с runtime delivery, parsing или task filtering. Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
|
||||
|
||||
## Changes не связаны с tasks
|
||||
|
||||
Используйте task-specific logs и code review links. Если diff выглядит detached, проверьте, был ли task id или task reference в output агента.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Если провайдер сообщает reset time, Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
|
||||
|
||||
## Какие данные собрать
|
||||
|
||||
Соберите:
|
||||
|
||||
- task id
|
||||
- team name
|
||||
- runtime path
|
||||
- launch log excerpt
|
||||
- provider/model
|
||||
- точный time window
|
||||
|
||||
Этого обычно хватает для диагностики launch и task lifecycle issues.
|
||||
|
||||
67
landing/product-docs/ru/index.md
Normal file
67
landing/product-docs/ru/index.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
layout: home
|
||||
hero:
|
||||
name: Документация Agent Teams
|
||||
text: Запускайте команды AI-агентов из локального desktop-приложения
|
||||
tagline: Создавайте команды, наблюдайте за канбан-доской, ревьюйте изменения и координируйте Claude, Codex, OpenCode и multimodel workflows без потери локального контроля.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Быстрый старт
|
||||
link: /ru/guide/quickstart
|
||||
- theme: alt
|
||||
text: Установка
|
||||
link: /ru/guide/installation
|
||||
- theme: alt
|
||||
text: Концепции
|
||||
link: /ru/reference/concepts
|
||||
features:
|
||||
- icon: "01"
|
||||
title: Командный workflow
|
||||
details: Опишите роли, запустите lead-агента и дайте команде разбивать, брать и координировать задачи.
|
||||
link: /ru/guide/create-team
|
||||
linkText: Создать команду
|
||||
- icon: "02"
|
||||
title: Живая канбан-доска
|
||||
details: Видно, как задачи проходят todo, progress, review, blocked и done во время работы агентов.
|
||||
link: /ru/guide/agent-workflow
|
||||
linkText: Разобрать workflow
|
||||
- icon: "03"
|
||||
title: Встроенное код-ревью
|
||||
details: Проверяйте diff по задаче, принимайте или отклоняйте hunks и оставляйте комментарии.
|
||||
link: /ru/guide/code-review
|
||||
linkText: Ревью изменений
|
||||
- icon: "04"
|
||||
title: Runtime-aware setup
|
||||
details: Используйте Claude, Codex, OpenCode или multimodel-провайдеры через доступ, который у вас уже есть.
|
||||
link: /ru/guide/runtime-setup
|
||||
linkText: Настроить рантаймы
|
||||
- icon: "05"
|
||||
title: Local-first контроль
|
||||
details: Приложение читает локальный проект и runtime-состояние. Код остаётся у вас, если выбранный провайдер не получает контекст для model call.
|
||||
link: /ru/reference/privacy-local-data
|
||||
linkText: Модель приватности
|
||||
- icon: "06"
|
||||
title: Диагностируемые команды
|
||||
details: Отслеживайте task logs, runtime output, сообщения агентов и live processes, когда запуск или задача застряли.
|
||||
link: /ru/guide/troubleshooting
|
||||
linkText: Диагностика
|
||||
---
|
||||
|
||||
<InstallBlock label="Скопировать" copied-label="Скопировано" />
|
||||
|
||||
## С чего начать
|
||||
|
||||
Agent Teams - бесплатное desktop-приложение для оркестрации команд AI-агентов. Это не просто одиночные промпты одному агенту: вы создаёте команду, задаёте роли и смотрите, как агенты координируют работу через task board.
|
||||
|
||||
<DocsCardGrid />
|
||||
|
||||
## Справочник
|
||||
|
||||
Используйте справочник, когда нужны точные термины, поведение провайдеров или границы приватности.
|
||||
|
||||
<DocsCardGrid type="reference" />
|
||||
|
||||
## Превью продукта
|
||||
|
||||
<ZoomImage src="/screenshots/1.jpg" alt="Канбан-доска Agent Teams" caption="Статусы задач, активность агентов и review workflow видны в одном рабочем пространстве." />
|
||||
|
||||
32
landing/product-docs/ru/reference/concepts.md
Normal file
32
landing/product-docs/ru/reference/concepts.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Концепции
|
||||
|
||||
Основные термины Agent Teams.
|
||||
|
||||
## Team
|
||||
|
||||
Team - группа агентов, настроенная для проекта. Обычно есть lead и один или несколько teammates со специализированными ролями.
|
||||
|
||||
## Lead
|
||||
|
||||
Lead координирует работу: разбивает цель на tasks, назначает teammates, отслеживает blockers и просит review.
|
||||
|
||||
## Task
|
||||
|
||||
Task - устойчивая единица работы. У неё есть status, description, comments, logs, attachments и reviewable changes.
|
||||
|
||||
## Solo mode
|
||||
|
||||
Solo mode запускает команду из одного агента. Полезно для маленьких задач, меньшего token usage и проверки prompt перед расширением до команды.
|
||||
|
||||
## Cross-team communication
|
||||
|
||||
Агенты могут писать внутри и между командами. Это нужно, когда разные teams владеют связанными частями работы.
|
||||
|
||||
## Autonomy level
|
||||
|
||||
Autonomy определяет, сколько агент может делать до запроса подтверждения. Больше автономности быстрее, меньше - безопаснее для sensitive code paths.
|
||||
|
||||
## Runtime
|
||||
|
||||
Runtime - локальный execution path, который соединяет Agent Teams с model/provider workflow, например Claude, Codex или OpenCode.
|
||||
|
||||
29
landing/product-docs/ru/reference/faq.md
Normal file
29
landing/product-docs/ru/reference/faq.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# FAQ
|
||||
|
||||
## Agent Teams бесплатный?
|
||||
|
||||
Да. Приложение бесплатное и open source. Provider или runtime access может стоить денег в зависимости от выбранного пути.
|
||||
|
||||
## Нужно ли заранее ставить Claude или Codex?
|
||||
|
||||
Не всегда. Приложение ведёт runtime detection и setup через UI. Некоторые пути всё равно требуют внешнюю авторизацию runtime.
|
||||
|
||||
## Приложение загружает мой код на серверы Agent Teams?
|
||||
|
||||
Нет. Agent Teams не является cloud code-sync сервисом. Но provider-backed model calls могут получать prompt context в зависимости от выбранного runtime.
|
||||
|
||||
## Агенты могут общаться друг с другом?
|
||||
|
||||
Да. Агенты могут писать teammates, комментировать tasks и координироваться между teams.
|
||||
|
||||
## Можно ревьюить код перед принятием?
|
||||
|
||||
Да. Review flow построен вокруг task-scoped diffs и hunk-level decisions.
|
||||
|
||||
## Что такое solo mode?
|
||||
|
||||
Solo mode - команда из одного агента. Подходит для небольших задач и меньшего coordination overhead.
|
||||
|
||||
## Что делать, если launch завис?
|
||||
|
||||
Откройте диагностику, соберите runtime logs и проверьте provider auth до изменения prompts.
|
||||
30
landing/product-docs/ru/reference/privacy-local-data.md
Normal file
30
landing/product-docs/ru/reference/privacy-local-data.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Приватность и локальные данные
|
||||
|
||||
Agent Teams local-first, но выбранный provider path всё равно важен.
|
||||
|
||||
## Что остаётся локально
|
||||
|
||||
Desktop app работает на вашей машине и читает локальные project/runtime data для UI:
|
||||
|
||||
- project files
|
||||
- task metadata
|
||||
- runtime/session logs
|
||||
- review state
|
||||
- local app settings
|
||||
|
||||
## Что может выйти с машины
|
||||
|
||||
Когда агент обращается к provider-backed model, prompt context и tool results могут отправляться через выбранный provider/runtime path. Это зависит от runtime и provider.
|
||||
|
||||
## Практические правила
|
||||
|
||||
- Не прикладывайте secrets к tasks.
|
||||
- Проверяйте provider policies для sensitive projects.
|
||||
- Используйте меньшую autonomy для risky repositories.
|
||||
- Держите task scope узким при работе с private code.
|
||||
- Для диагностики опирайтесь на local evidence и logs.
|
||||
|
||||
## Open source
|
||||
|
||||
Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking и review flows.
|
||||
|
||||
40
landing/product-docs/ru/reference/providers-runtimes.md
Normal file
40
landing/product-docs/ru/reference/providers-runtimes.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Провайдеры и рантаймы
|
||||
|
||||
Agent Teams отделяет orchestration от model access.
|
||||
|
||||
## Что даёт приложение
|
||||
|
||||
Agent Teams даёт:
|
||||
|
||||
- orchestration команд и задач
|
||||
- kanban board UI
|
||||
- teammate messaging
|
||||
- task logs
|
||||
- review UI
|
||||
- local project integration
|
||||
|
||||
## Что даёт runtime
|
||||
|
||||
Runtime отвечает за:
|
||||
|
||||
- model execution
|
||||
- provider authentication
|
||||
- tool execution behavior
|
||||
- rate limits и capabilities конкретной модели
|
||||
|
||||
## Частые варианты
|
||||
|
||||
| Runtime | Заметки |
|
||||
| --- | --- |
|
||||
| Claude | Хорошо для Claude Code users и Anthropic access |
|
||||
| Codex | Хорошо для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Хорошо для multimodel routing и широкой provider coverage |
|
||||
|
||||
## Стоимость providers
|
||||
|
||||
Agent Teams бесплатен. Стоимость provider usage зависит от выбранного runtime/provider.
|
||||
|
||||
## Capability checks
|
||||
|
||||
Во время setup приложение может выполнять access и capability checks. Это помогает найти отсутствующую авторизацию до того, как team launch застрянет в provisioning.
|
||||
|
||||
|
|
@ -7,5 +7,6 @@ export default defineEventHandler((event) => {
|
|||
return `User-agent: *
|
||||
Allow: /
|
||||
Sitemap: ${siteUrl}/sitemap.xml
|
||||
Sitemap: ${siteUrl}/docs/sitemap.xml
|
||||
`;
|
||||
});
|
||||
|
|
|
|||
1292
pnpm-lock.yaml
1292
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -90,6 +90,7 @@ export const GraphProvisioningHud = ({
|
|||
<StepProgressBar
|
||||
steps={MINI_STEPS}
|
||||
currentIndex={presentation.currentStepIndex}
|
||||
active={presentation.isActive}
|
||||
errorIndex={errorStepIndex}
|
||||
className="w-full origin-top scale-[0.88]"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ import {
|
|||
type SessionsPaginationOptions,
|
||||
type WorktreeSource,
|
||||
} from '@main/types';
|
||||
import { analyzeSessionFileMetadata, extractCwd } from '@main/utils/jsonl';
|
||||
import {
|
||||
analyzeSessionFileMetadata,
|
||||
extractCwd,
|
||||
type SessionFileMetadata,
|
||||
} from '@main/utils/jsonl';
|
||||
import {
|
||||
buildSessionPath,
|
||||
buildSubagentsPath,
|
||||
|
|
@ -60,6 +64,7 @@ import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvid
|
|||
import { ProjectPathResolver } from './ProjectPathResolver';
|
||||
import { resolveProjectStorageDir as resolveProjectStorageDirFromCandidates } from './projectStorageDir';
|
||||
import { SessionContentFilter } from './SessionContentFilter';
|
||||
import { type SessionFileSignature, SessionMetadataIndex } from './SessionMetadataIndex';
|
||||
import { SessionSearcher } from './SessionSearcher';
|
||||
import { SubagentLocator } from './SubagentLocator';
|
||||
import { subprojectRegistry } from './SubprojectRegistry';
|
||||
|
|
@ -77,10 +82,24 @@ const SEARCH_PROJECT_CACHE_TTL_MS = 30_000;
|
|||
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
|
||||
const MAX_SESSION_IDS_EXPORTED = 200;
|
||||
|
||||
export interface ProjectScannerOptions {
|
||||
/**
|
||||
* Directory for the persisted session-list metadata index.
|
||||
* Defaults to a sibling of the configured projects directory.
|
||||
*/
|
||||
sessionIndexDir?: string;
|
||||
/** Test hook: set to 0 to persist index files without debounce. */
|
||||
sessionIndexPersistDelayMs?: number;
|
||||
}
|
||||
|
||||
function splitPathSegments(value: string): string[] {
|
||||
return value.split(/[/\\]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function getDefaultSessionIndexDir(projectsDir: string): string {
|
||||
return path.join(path.dirname(projectsDir), '.agent-teams-session-index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast, zero-I/O worktree detection based on path patterns only.
|
||||
* Used by scanWithWorktreeGrouping to provide accurate worktree metadata
|
||||
|
|
@ -164,8 +183,14 @@ export class ProjectScanner {
|
|||
private readonly subagentLocator: SubagentLocator;
|
||||
private readonly sessionSearcher: SessionSearcher;
|
||||
private readonly projectPathResolver: ProjectPathResolver;
|
||||
private readonly sessionMetadataIndex: SessionMetadataIndex | null;
|
||||
|
||||
constructor(projectsDir?: string, todosDir?: string, fsProvider?: FileSystemProvider) {
|
||||
constructor(
|
||||
projectsDir?: string,
|
||||
todosDir?: string,
|
||||
fsProvider?: FileSystemProvider,
|
||||
options?: ProjectScannerOptions
|
||||
) {
|
||||
this.projectsDir = projectsDir ?? getProjectsBasePath();
|
||||
this.todosDir = todosDir ?? getTodosBasePath();
|
||||
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
|
||||
|
|
@ -175,6 +200,13 @@ export class ProjectScanner {
|
|||
this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider);
|
||||
this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider);
|
||||
this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider);
|
||||
this.sessionMetadataIndex =
|
||||
this.fsProvider.type === 'local'
|
||||
? new SessionMetadataIndex({
|
||||
rootDir: options?.sessionIndexDir ?? getDefaultSessionIndexDir(this.projectsDir),
|
||||
persistDelayMs: options?.sessionIndexPersistDelayMs,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -643,7 +675,14 @@ export class ProjectScanner {
|
|||
}
|
||||
|
||||
const entries = await this.fsProvider.readdir(projectPath);
|
||||
let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));
|
||||
const allSessionFiles = entries.filter(
|
||||
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
|
||||
);
|
||||
await this.pruneSessionMetadataIndex(
|
||||
projectPath,
|
||||
new Set(allSessionFiles.map((file) => path.join(projectPath, file.name)))
|
||||
);
|
||||
let sessionFiles = allSessionFiles;
|
||||
|
||||
// Filter to only sessions belonging to this subproject
|
||||
if (sessionFilter) {
|
||||
|
|
@ -733,7 +772,14 @@ export class ProjectScanner {
|
|||
|
||||
// Step 1: Get all session files with their timestamps (lightweight stat calls)
|
||||
const entries = await this.fsProvider.readdir(projectPath);
|
||||
let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));
|
||||
const allSessionFiles = entries.filter(
|
||||
(entry) => entry.isFile() && entry.name.endsWith('.jsonl')
|
||||
);
|
||||
await this.pruneSessionMetadataIndex(
|
||||
projectPath,
|
||||
new Set(allSessionFiles.map((file) => path.join(projectPath, file.name)))
|
||||
);
|
||||
let sessionFiles = allSessionFiles;
|
||||
|
||||
// Filter to only sessions belonging to this subproject
|
||||
if (sessionFilter) {
|
||||
|
|
@ -967,18 +1013,14 @@ export class ProjectScanner {
|
|||
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||
const effectiveSize = prefetchedSize ?? stats?.size ?? -1;
|
||||
const birthtimeMs = prefetchedBirthtimeMs ?? stats?.birthtimeMs ?? effectiveMtime;
|
||||
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
||||
const metadata =
|
||||
cachedMetadata?.mtimeMs === effectiveMtime && cachedMetadata.size === effectiveSize
|
||||
? cachedMetadata.metadata
|
||||
: await analyzeSessionFileMetadata(filePath, this.fsProvider);
|
||||
if (cachedMetadata?.mtimeMs !== effectiveMtime || cachedMetadata.size !== effectiveSize) {
|
||||
this.sessionMetadataCache.set(filePath, {
|
||||
mtimeMs: effectiveMtime,
|
||||
size: effectiveSize,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
const signature = this.buildSessionFileSignature(
|
||||
sessionId,
|
||||
filePath,
|
||||
effectiveMtime,
|
||||
effectiveSize,
|
||||
birthtimeMs
|
||||
);
|
||||
const metadata = await this.getSessionFileMetadata(signature);
|
||||
|
||||
// Check for subagents (todoData skipped here — loaded on-demand in detail view)
|
||||
const hasSubagents = await this.subagentLocator.hasSubagents(projectId, sessionId);
|
||||
|
|
@ -1035,18 +1077,16 @@ export class ProjectScanner {
|
|||
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||
const effectiveSize = prefetchedSize ?? stats?.size ?? -1;
|
||||
const birthtimeMs = prefetchedBirthtimeMs ?? stats?.birthtimeMs ?? effectiveMtime;
|
||||
let metadata: Awaited<ReturnType<typeof analyzeSessionFileMetadata>>;
|
||||
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
||||
if (cachedMetadata?.mtimeMs === effectiveMtime && cachedMetadata.size === effectiveSize) {
|
||||
metadata = cachedMetadata.metadata;
|
||||
} else {
|
||||
let metadata: SessionFileMetadata;
|
||||
const signature = this.buildSessionFileSignature(
|
||||
sessionId,
|
||||
filePath,
|
||||
effectiveMtime,
|
||||
effectiveSize,
|
||||
birthtimeMs
|
||||
);
|
||||
try {
|
||||
metadata = await analyzeSessionFileMetadata(filePath, this.fsProvider);
|
||||
this.sessionMetadataCache.set(filePath, {
|
||||
mtimeMs: effectiveMtime,
|
||||
size: effectiveSize,
|
||||
metadata,
|
||||
});
|
||||
metadata = await this.getSessionFileMetadata(signature);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to analyze session metadata for ${filePath}:`, error);
|
||||
metadata = {
|
||||
|
|
@ -1057,7 +1097,6 @@ export class ProjectScanner {
|
|||
model: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const metadataLevel: SessionMetadataLevel = 'light';
|
||||
const previewTimestampMs = this.parseTimestampMs(metadata.firstUserMessage?.timestamp);
|
||||
const createdAt =
|
||||
|
|
@ -1421,6 +1460,94 @@ export class ProjectScanner {
|
|||
}
|
||||
}
|
||||
|
||||
async flushSessionMetadataIndexForTesting(): Promise<void> {
|
||||
await this.sessionMetadataIndex?.flushForTesting();
|
||||
}
|
||||
|
||||
private buildSessionFileSignature(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
mtimeMs: number,
|
||||
size: number,
|
||||
birthtimeMs?: number
|
||||
): SessionFileSignature {
|
||||
return {
|
||||
sessionId,
|
||||
filePath,
|
||||
mtimeMs,
|
||||
size,
|
||||
birthtimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
private async getSessionFileMetadata(
|
||||
signature: SessionFileSignature
|
||||
): Promise<SessionFileMetadata> {
|
||||
const cachedMetadata = this.sessionMetadataCache.get(signature.filePath);
|
||||
if (cachedMetadata?.mtimeMs === signature.mtimeMs && cachedMetadata.size === signature.size) {
|
||||
return cachedMetadata.metadata;
|
||||
}
|
||||
|
||||
let indexedMetadata: SessionFileMetadata | undefined;
|
||||
if (this.sessionMetadataIndex) {
|
||||
try {
|
||||
indexedMetadata = await this.sessionMetadataIndex.getMetadata(signature);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to read session metadata index for ${signature.filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (indexedMetadata) {
|
||||
this.sessionMetadataCache.set(signature.filePath, {
|
||||
mtimeMs: signature.mtimeMs,
|
||||
size: signature.size,
|
||||
metadata: indexedMetadata,
|
||||
});
|
||||
return indexedMetadata;
|
||||
}
|
||||
|
||||
const metadata = await analyzeSessionFileMetadata(signature.filePath, this.fsProvider);
|
||||
this.sessionMetadataCache.set(signature.filePath, {
|
||||
mtimeMs: signature.mtimeMs,
|
||||
size: signature.size,
|
||||
metadata,
|
||||
});
|
||||
if (this.sessionMetadataIndex) {
|
||||
try {
|
||||
await this.sessionMetadataIndex.setMetadata(signature, metadata);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to update session metadata index for ${signature.filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private async pruneSessionMetadataIndex(
|
||||
projectStorageDir: string,
|
||||
existingFilePaths: Set<string>
|
||||
): Promise<void> {
|
||||
if (!this.sessionMetadataIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sessionMetadataIndex.pruneMissing(projectStorageDir, existingFilePaths);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to prune session metadata index for ${projectStorageDir}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve best-available file timestamps from directory entry metadata or stat fallback.
|
||||
*/
|
||||
|
|
@ -1545,11 +1672,39 @@ export class ProjectScanner {
|
|||
const stats = hasPrefetched ? null : await this.fsProvider.stat(filePath);
|
||||
const effectiveMtime = mtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||
const effectiveSize = size ?? stats?.size ?? -1;
|
||||
const signature = this.buildSessionFileSignature(
|
||||
extractSessionId(path.basename(filePath)),
|
||||
filePath,
|
||||
effectiveMtime,
|
||||
effectiveSize,
|
||||
stats?.birthtimeMs
|
||||
);
|
||||
const cached = this.contentPresenceCache.get(filePath);
|
||||
if (cached?.mtimeMs === effectiveMtime && cached.size === effectiveSize) {
|
||||
return cached.hasContent;
|
||||
}
|
||||
|
||||
let indexed: boolean | undefined;
|
||||
if (this.sessionMetadataIndex) {
|
||||
try {
|
||||
indexed = await this.sessionMetadataIndex.getContentPresence(signature);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to read content-presence index for ${filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (typeof indexed === 'boolean') {
|
||||
this.contentPresenceCache.set(filePath, {
|
||||
mtimeMs: effectiveMtime,
|
||||
size: effectiveSize,
|
||||
hasContent: indexed,
|
||||
});
|
||||
return indexed;
|
||||
}
|
||||
|
||||
const hasContent = await this.sessionContentFilter.hasNonNoiseMessages(
|
||||
filePath,
|
||||
this.fsProvider
|
||||
|
|
@ -1559,6 +1714,17 @@ export class ProjectScanner {
|
|||
size: effectiveSize,
|
||||
hasContent,
|
||||
});
|
||||
if (this.sessionMetadataIndex) {
|
||||
try {
|
||||
await this.sessionMetadataIndex.setContentPresence(signature, hasContent);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Failed to update content-presence index for ${filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return hasContent;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
|
|||
511
src/main/services/discovery/SessionMetadataIndex.ts
Normal file
511
src/main/services/discovery/SessionMetadataIndex.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
/**
|
||||
* SessionMetadataIndex - persisted read-through cache for session listing metadata.
|
||||
*
|
||||
* The index is never a source of truth. Callers may use an entry only when the
|
||||
* current file signature (mtimeMs + size) matches the indexed signature.
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { SessionFileMetadata } from '@main/utils/jsonl';
|
||||
|
||||
const logger = createLogger('Discovery:SessionMetadataIndex');
|
||||
|
||||
const SESSION_METADATA_INDEX_SCHEMA_VERSION = 1;
|
||||
const DEFAULT_PERSIST_DELAY_MS = 250;
|
||||
|
||||
export interface SessionMetadataIndexOptions {
|
||||
rootDir: string;
|
||||
persistDelayMs?: number;
|
||||
}
|
||||
|
||||
export interface SessionFileSignature {
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
birthtimeMs?: number;
|
||||
}
|
||||
|
||||
interface SessionMetadataIndexEntry extends SessionFileSignature {
|
||||
hasContent?: boolean;
|
||||
metadata?: SessionFileMetadata;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface SessionMetadataIndexFile {
|
||||
schemaVersion: number;
|
||||
projectStorageDir: string;
|
||||
updatedAt: number;
|
||||
sessions: Record<string, SessionMetadataIndexEntry>;
|
||||
}
|
||||
|
||||
interface LoadedProjectIndex {
|
||||
file: SessionMetadataIndexFile;
|
||||
dirty: boolean;
|
||||
persistTimer: ReturnType<typeof setTimeout> | null;
|
||||
persistPromise: Promise<void> | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasOwnProperty(value: Record<string, unknown>, property: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, property);
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown): value is number {
|
||||
return isFiniteNumber(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value: string): string {
|
||||
const replaced = value.replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||
let start = 0;
|
||||
let end = replaced.length;
|
||||
while (start < end && replaced[start] === '-') {
|
||||
start += 1;
|
||||
}
|
||||
while (end > start && replaced[end - 1] === '-') {
|
||||
end -= 1;
|
||||
}
|
||||
const sanitized = replaced.slice(start, end);
|
||||
return sanitized.length > 0 ? sanitized.slice(0, 80) : 'project';
|
||||
}
|
||||
|
||||
function hashProjectStorageDir(projectStorageDir: string): string {
|
||||
return crypto.createHash('sha256').update(projectStorageDir).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function createEmptyIndex(projectStorageDir: string): SessionMetadataIndexFile {
|
||||
return {
|
||||
schemaVersion: SESSION_METADATA_INDEX_SCHEMA_VERSION,
|
||||
projectStorageDir,
|
||||
updatedAt: Date.now(),
|
||||
sessions: {},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFirstUserMessage(
|
||||
value: unknown
|
||||
): SessionFileMetadata['firstUserMessage'] | undefined {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (isRecord(value) && typeof value.text === 'string' && typeof value.timestamp === 'string') {
|
||||
return {
|
||||
text: value.text,
|
||||
timestamp: value.timestamp,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizePhaseBreakdown(
|
||||
value: unknown
|
||||
): SessionFileMetadata['phaseBreakdown'] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const phases: NonNullable<SessionFileMetadata['phaseBreakdown']> = [];
|
||||
for (const phase of value) {
|
||||
if (
|
||||
!isRecord(phase) ||
|
||||
!isNonNegativeInteger(phase.phaseNumber) ||
|
||||
!isNonNegativeInteger(phase.contribution) ||
|
||||
!isNonNegativeInteger(phase.peakTokens) ||
|
||||
(hasOwnProperty(phase, 'postCompaction') && !isNonNegativeInteger(phase.postCompaction))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
phases.push({
|
||||
phaseNumber: phase.phaseNumber,
|
||||
contribution: phase.contribution,
|
||||
peakTokens: phase.peakTokens,
|
||||
...(isNonNegativeInteger(phase.postCompaction)
|
||||
? { postCompaction: phase.postCompaction }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return phases;
|
||||
}
|
||||
|
||||
function normalizeMetadata(value: unknown): SessionFileMetadata | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstUserMessage = normalizeFirstUserMessage(value.firstUserMessage);
|
||||
if (
|
||||
firstUserMessage === undefined ||
|
||||
!isNonNegativeInteger(value.messageCount) ||
|
||||
typeof value.isOngoing !== 'boolean' ||
|
||||
!(value.gitBranch === null || typeof value.gitBranch === 'string')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const metadata: SessionFileMetadata = {
|
||||
firstUserMessage,
|
||||
messageCount: value.messageCount,
|
||||
isOngoing: value.isOngoing,
|
||||
gitBranch: value.gitBranch,
|
||||
};
|
||||
|
||||
if (hasOwnProperty(value, 'model')) {
|
||||
if (!(value.model === null || typeof value.model === 'string')) {
|
||||
return undefined;
|
||||
}
|
||||
metadata.model = value.model;
|
||||
}
|
||||
if (hasOwnProperty(value, 'contextConsumption')) {
|
||||
if (!isNonNegativeInteger(value.contextConsumption)) {
|
||||
return undefined;
|
||||
}
|
||||
metadata.contextConsumption = value.contextConsumption;
|
||||
}
|
||||
if (hasOwnProperty(value, 'compactionCount')) {
|
||||
if (!isNonNegativeInteger(value.compactionCount)) {
|
||||
return undefined;
|
||||
}
|
||||
metadata.compactionCount = value.compactionCount;
|
||||
}
|
||||
|
||||
if (hasOwnProperty(value, 'phaseBreakdown')) {
|
||||
const phaseBreakdown = normalizePhaseBreakdown(value.phaseBreakdown);
|
||||
if (!phaseBreakdown) {
|
||||
return undefined;
|
||||
}
|
||||
metadata.phaseBreakdown = phaseBreakdown;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function normalizeEntry(value: unknown): SessionMetadataIndexEntry | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof value.sessionId !== 'string' ||
|
||||
typeof value.filePath !== 'string' ||
|
||||
!isNonNegativeFiniteNumber(value.mtimeMs) ||
|
||||
!isNonNegativeInteger(value.size) ||
|
||||
!isNonNegativeFiniteNumber(value.updatedAt)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry: SessionMetadataIndexEntry = {
|
||||
sessionId: value.sessionId,
|
||||
filePath: value.filePath,
|
||||
mtimeMs: value.mtimeMs,
|
||||
size: value.size,
|
||||
updatedAt: value.updatedAt,
|
||||
};
|
||||
|
||||
if (hasOwnProperty(value, 'birthtimeMs')) {
|
||||
if (!isNonNegativeFiniteNumber(value.birthtimeMs)) {
|
||||
return null;
|
||||
}
|
||||
entry.birthtimeMs = value.birthtimeMs;
|
||||
}
|
||||
if (hasOwnProperty(value, 'hasContent')) {
|
||||
if (typeof value.hasContent !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
entry.hasContent = value.hasContent;
|
||||
}
|
||||
const metadata = normalizeMetadata(value.metadata);
|
||||
if (metadata) {
|
||||
entry.metadata = metadata;
|
||||
} else if (hasOwnProperty(value, 'metadata')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
function normalizeIndexFile(
|
||||
value: unknown,
|
||||
projectStorageDir: string
|
||||
): SessionMetadataIndexFile | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
value.schemaVersion !== SESSION_METADATA_INDEX_SCHEMA_VERSION ||
|
||||
value.projectStorageDir !== projectStorageDir ||
|
||||
!isRecord(value.sessions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessions: Record<string, SessionMetadataIndexEntry> = {};
|
||||
for (const [key, rawEntry] of Object.entries(value.sessions)) {
|
||||
const entry = normalizeEntry(rawEntry);
|
||||
if (key === entry?.filePath) {
|
||||
sessions[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: SESSION_METADATA_INDEX_SCHEMA_VERSION,
|
||||
projectStorageDir,
|
||||
updatedAt: isNonNegativeFiniteNumber(value.updatedAt) ? value.updatedAt : Date.now(),
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
function isFreshEntry(
|
||||
entry: SessionMetadataIndexEntry | undefined,
|
||||
signature: Pick<SessionFileSignature, 'sessionId' | 'filePath' | 'mtimeMs' | 'size'>
|
||||
): entry is SessionMetadataIndexEntry {
|
||||
return (
|
||||
Boolean(entry) &&
|
||||
entry!.sessionId === signature.sessionId &&
|
||||
entry!.filePath === signature.filePath &&
|
||||
entry!.mtimeMs === signature.mtimeMs &&
|
||||
entry!.size === signature.size
|
||||
);
|
||||
}
|
||||
|
||||
export class SessionMetadataIndex {
|
||||
private readonly rootDir: string;
|
||||
private readonly persistDelayMs: number;
|
||||
private readonly indexes = new Map<string, LoadedProjectIndex>();
|
||||
private readonly loads = new Map<string, Promise<LoadedProjectIndex>>();
|
||||
|
||||
constructor(options: SessionMetadataIndexOptions) {
|
||||
this.rootDir = options.rootDir;
|
||||
this.persistDelayMs = options.persistDelayMs ?? DEFAULT_PERSIST_DELAY_MS;
|
||||
}
|
||||
|
||||
static getIndexPath(rootDir: string, projectStorageDir: string): string {
|
||||
const basename = sanitizePathSegment(path.basename(projectStorageDir));
|
||||
const hash = hashProjectStorageDir(projectStorageDir);
|
||||
return path.join(rootDir, `${basename}-${hash}.json`);
|
||||
}
|
||||
|
||||
async getContentPresence(signature: SessionFileSignature): Promise<boolean | undefined> {
|
||||
const index = await this.loadProjectIndex(path.dirname(signature.filePath));
|
||||
const entry = index.file.sessions[signature.filePath];
|
||||
if (!isFreshEntry(entry, signature)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof entry.hasContent === 'boolean' ? entry.hasContent : undefined;
|
||||
}
|
||||
|
||||
async setContentPresence(signature: SessionFileSignature, hasContent: boolean): Promise<void> {
|
||||
const projectStorageDir = path.dirname(signature.filePath);
|
||||
const index = await this.loadProjectIndex(projectStorageDir);
|
||||
const entry = this.getOrCreateEntry(index.file, signature);
|
||||
entry.hasContent = hasContent;
|
||||
entry.updatedAt = Date.now();
|
||||
this.markDirty(projectStorageDir, index);
|
||||
}
|
||||
|
||||
async getMetadata(signature: SessionFileSignature): Promise<SessionFileMetadata | undefined> {
|
||||
const index = await this.loadProjectIndex(path.dirname(signature.filePath));
|
||||
const entry = index.file.sessions[signature.filePath];
|
||||
if (!isFreshEntry(entry, signature)) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.metadata;
|
||||
}
|
||||
|
||||
async setMetadata(signature: SessionFileSignature, metadata: SessionFileMetadata): Promise<void> {
|
||||
const projectStorageDir = path.dirname(signature.filePath);
|
||||
const index = await this.loadProjectIndex(projectStorageDir);
|
||||
const entry = this.getOrCreateEntry(index.file, signature);
|
||||
entry.metadata = metadata;
|
||||
entry.updatedAt = Date.now();
|
||||
this.markDirty(projectStorageDir, index);
|
||||
}
|
||||
|
||||
async pruneMissing(projectStorageDir: string, existingFilePaths: Set<string>): Promise<void> {
|
||||
const index = await this.loadProjectIndex(projectStorageDir);
|
||||
let changed = false;
|
||||
for (const filePath of Object.keys(index.file.sessions)) {
|
||||
if (!existingFilePaths.has(filePath)) {
|
||||
delete index.file.sessions[filePath];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.markDirty(projectStorageDir, index);
|
||||
}
|
||||
}
|
||||
|
||||
async flushForTesting(): Promise<void> {
|
||||
for (const [projectStorageDir, index] of this.indexes.entries()) {
|
||||
if (index.persistTimer) {
|
||||
clearTimeout(index.persistTimer);
|
||||
index.persistTimer = null;
|
||||
}
|
||||
|
||||
while (index.dirty || index.persistPromise) {
|
||||
if (index.dirty) {
|
||||
this.startPersist(projectStorageDir, index);
|
||||
}
|
||||
await (index.persistPromise ?? Promise.resolve());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateEntry(
|
||||
index: SessionMetadataIndexFile,
|
||||
signature: SessionFileSignature
|
||||
): SessionMetadataIndexEntry {
|
||||
const existing = index.sessions[signature.filePath];
|
||||
if (isFreshEntry(existing, signature)) {
|
||||
if (isNonNegativeFiniteNumber(signature.birthtimeMs)) {
|
||||
existing.birthtimeMs = signature.birthtimeMs;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const entry: SessionMetadataIndexEntry = {
|
||||
sessionId: signature.sessionId,
|
||||
filePath: signature.filePath,
|
||||
mtimeMs: signature.mtimeMs,
|
||||
size: signature.size,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (isNonNegativeFiniteNumber(signature.birthtimeMs)) {
|
||||
entry.birthtimeMs = signature.birthtimeMs;
|
||||
}
|
||||
index.sessions[signature.filePath] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async loadProjectIndex(projectStorageDir: string): Promise<LoadedProjectIndex> {
|
||||
const cached = this.indexes.get(projectStorageDir);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existingLoad = this.loads.get(projectStorageDir);
|
||||
if (existingLoad) {
|
||||
return existingLoad;
|
||||
}
|
||||
|
||||
const load = this.readProjectIndex(projectStorageDir).finally(() => {
|
||||
this.loads.delete(projectStorageDir);
|
||||
});
|
||||
this.loads.set(projectStorageDir, load);
|
||||
return load;
|
||||
}
|
||||
|
||||
private async readProjectIndex(projectStorageDir: string): Promise<LoadedProjectIndex> {
|
||||
const indexPath = SessionMetadataIndex.getIndexPath(this.rootDir, projectStorageDir);
|
||||
let file = createEmptyIndex(projectStorageDir);
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath, 'utf8');
|
||||
const parsed = normalizeIndexFile(JSON.parse(raw), projectStorageDir);
|
||||
if (parsed) {
|
||||
file = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
const code = isRecord(error) ? error.code : undefined;
|
||||
if (code !== 'ENOENT') {
|
||||
logger.debug(
|
||||
`Ignoring unreadable session metadata index ${indexPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const loaded: LoadedProjectIndex = {
|
||||
file,
|
||||
dirty: false,
|
||||
persistTimer: null,
|
||||
persistPromise: null,
|
||||
};
|
||||
this.indexes.set(projectStorageDir, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private markDirty(projectStorageDir: string, index: LoadedProjectIndex): void {
|
||||
index.file.updatedAt = Date.now();
|
||||
index.dirty = true;
|
||||
|
||||
if (this.persistDelayMs <= 0) {
|
||||
this.startPersist(projectStorageDir, index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (index.persistTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
index.persistTimer = setTimeout(() => {
|
||||
index.persistTimer = null;
|
||||
this.startPersist(projectStorageDir, index);
|
||||
}, this.persistDelayMs);
|
||||
index.persistTimer.unref?.();
|
||||
}
|
||||
|
||||
private startPersist(projectStorageDir: string, index: LoadedProjectIndex): void {
|
||||
if (index.persistPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = this.persistProjectIndex(projectStorageDir, index).finally(() => {
|
||||
if (index.persistPromise === promise) {
|
||||
index.persistPromise = null;
|
||||
}
|
||||
if (index.dirty) {
|
||||
this.markDirty(projectStorageDir, index);
|
||||
}
|
||||
});
|
||||
index.persistPromise = promise;
|
||||
}
|
||||
|
||||
private async persistProjectIndex(
|
||||
projectStorageDir: string,
|
||||
index: LoadedProjectIndex
|
||||
): Promise<void> {
|
||||
if (!index.dirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexPath = SessionMetadataIndex.getIndexPath(this.rootDir, projectStorageDir);
|
||||
const tmpPath = `${indexPath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
||||
const serialized = `${JSON.stringify(index.file)}\n`;
|
||||
index.dirty = false;
|
||||
|
||||
try {
|
||||
await fs.mkdir(this.rootDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(tmpPath, serialized, { encoding: 'utf8', mode: 0o600 });
|
||||
await fs.rename(tmpPath, indexPath);
|
||||
} catch (error) {
|
||||
try {
|
||||
await fs.unlink(tmpPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
logger.debug(
|
||||
`Failed to persist session metadata index ${indexPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2308,7 +2308,7 @@ const OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON =
|
|||
'OpenCode bridge reported member launch failure';
|
||||
const OPEN_CODE_SECRET_FLAG_PATTERN =
|
||||
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
|
||||
const OPEN_CODE_BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
|
||||
const OPEN_CODE_BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Z0-9._~+/=-]+/gi;
|
||||
const OPEN_CODE_SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g;
|
||||
|
||||
function normalizeOpenCodePersistedFailureReason(value: string | undefined): string | undefined {
|
||||
|
|
@ -25533,37 +25533,51 @@ export class TeamProvisioningService {
|
|||
const config = ConfigManager.getInstance().getConfig();
|
||||
const suppressToast = !config.notifications.notifyOnTeamLaunched;
|
||||
const displayName = run.request.displayName || run.teamName;
|
||||
const expectedMembers =
|
||||
snapshot?.expectedMembers ??
|
||||
run.expectedMembers ??
|
||||
run.allEffectiveMembers.map((member) => member.name).filter(Boolean);
|
||||
const expectedMembers = [
|
||||
...new Set(
|
||||
[
|
||||
...(snapshot?.expectedMembers ?? []),
|
||||
...(run.expectedMembers ?? []),
|
||||
...run.allEffectiveMembers.map((member) => member.name).filter(Boolean),
|
||||
].filter(Boolean)
|
||||
),
|
||||
];
|
||||
const expectedCount = expectedMembers.length;
|
||||
if (expectedCount === 0) return;
|
||||
|
||||
const failedNames = failedMembers.map((member) => member.name).filter(Boolean);
|
||||
const pendingNames =
|
||||
snapshot?.expectedMembers.filter((memberName) => {
|
||||
if (failedNames.includes(memberName)) return false;
|
||||
const member = snapshot.members[memberName];
|
||||
if (!member) return false;
|
||||
return (
|
||||
member.launchState !== 'confirmed_alive' && member.launchState !== 'skipped_for_launch'
|
||||
const failedNames = this.getLaunchIncompleteFailedNames(
|
||||
run,
|
||||
expectedMembers,
|
||||
failedMembers,
|
||||
snapshot
|
||||
);
|
||||
}) ?? [];
|
||||
const missingNames = failedNames.length > 0 ? failedNames : pendingNames;
|
||||
const missingCount =
|
||||
missingNames.length > 0
|
||||
? missingNames.length
|
||||
: Math.max(0, launchSummary.pendingCount + launchSummary.failedCount);
|
||||
const rawJoinedCount =
|
||||
typeof launchSummary.confirmedCount === 'number'
|
||||
? launchSummary.confirmedCount
|
||||
: expectedCount - missingCount;
|
||||
const joinedCount = Math.max(0, Math.min(expectedCount, rawJoinedCount));
|
||||
const missingLabel =
|
||||
missingNames.length > 0
|
||||
? `${missingNames.map((name) => `@${name}`).join(', ')} did not join`
|
||||
: `${missingCount} teammate${missingCount === 1 ? '' : 's'} did not join`;
|
||||
const pendingNames = this.getLaunchIncompletePendingNames(
|
||||
run,
|
||||
expectedMembers,
|
||||
failedNames,
|
||||
snapshot
|
||||
);
|
||||
const joinedCount = this.getLaunchIncompleteJoinedCount(
|
||||
run,
|
||||
expectedMembers,
|
||||
failedNames.length + pendingNames.length,
|
||||
launchSummary,
|
||||
snapshot
|
||||
);
|
||||
const missingCount = Math.max(0, launchSummary.pendingCount + launchSummary.failedCount);
|
||||
const bodyParts = [`${joinedCount}/${expectedCount} joined`];
|
||||
if (failedNames.length > 0) {
|
||||
bodyParts.push(`failed: ${this.formatLaunchIncompleteMemberMentions(failedNames)}`);
|
||||
}
|
||||
if (pendingNames.length > 0) {
|
||||
bodyParts.push(`still joining: ${this.formatLaunchIncompleteMemberMentions(pendingNames)}`);
|
||||
}
|
||||
if (bodyParts.length === 1 && missingCount > 0 && joinedCount < expectedCount) {
|
||||
const genericMissingCount = Math.min(missingCount, expectedCount - joinedCount);
|
||||
bodyParts.push(
|
||||
`${genericMissingCount} teammate${genericMissingCount === 1 ? '' : 's'} not joined yet`
|
||||
);
|
||||
}
|
||||
|
||||
await NotificationManager.getInstance().addTeamNotification({
|
||||
teamEventType: 'team_launch_incomplete',
|
||||
|
|
@ -25571,7 +25585,7 @@ export class TeamProvisioningService {
|
|||
teamDisplayName: displayName,
|
||||
from: 'system',
|
||||
summary: 'Team launch incomplete',
|
||||
body: `${joinedCount}/${expectedCount} joined · ${missingLabel}`,
|
||||
body: bodyParts.join(' · '),
|
||||
dedupeKey: `team_launch_incomplete:${run.teamName}:${run.runId}`,
|
||||
target: { kind: 'team', teamName: run.teamName, section: 'members' },
|
||||
projectPath: run.request.cwd,
|
||||
|
|
@ -25586,6 +25600,113 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private getLaunchIncompleteMemberEvidence(
|
||||
run: ProvisioningRun,
|
||||
snapshot: PersistedTeamLaunchSnapshot | null | undefined,
|
||||
memberName: string
|
||||
): {
|
||||
live?: MemberSpawnStatusEntry;
|
||||
persisted?: PersistedTeamLaunchMemberState;
|
||||
} {
|
||||
return {
|
||||
live: run.memberSpawnStatuses?.get(memberName),
|
||||
persisted: snapshot?.members[memberName],
|
||||
};
|
||||
}
|
||||
|
||||
private formatLaunchIncompleteMemberMentions(names: readonly string[]): string {
|
||||
return names.map((name) => `@${name}`).join(', ');
|
||||
}
|
||||
|
||||
private getLaunchIncompleteFailedNames(
|
||||
run: ProvisioningRun,
|
||||
expectedMembers: readonly string[],
|
||||
failedMembers: readonly { name: string }[],
|
||||
snapshot?: PersistedTeamLaunchSnapshot | null
|
||||
): string[] {
|
||||
const failedNames = new Set(failedMembers.map((member) => member.name).filter(Boolean));
|
||||
for (const memberName of expectedMembers) {
|
||||
const { live, persisted } = this.getLaunchIncompleteMemberEvidence(run, snapshot, memberName);
|
||||
if (
|
||||
live?.launchState === 'failed_to_start' ||
|
||||
persisted?.launchState === 'failed_to_start' ||
|
||||
live?.hardFailure === true ||
|
||||
persisted?.hardFailure === true
|
||||
) {
|
||||
failedNames.add(memberName);
|
||||
}
|
||||
}
|
||||
return [...failedNames].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private getLaunchIncompletePendingNames(
|
||||
run: ProvisioningRun,
|
||||
expectedMembers: readonly string[],
|
||||
failedNames: readonly string[],
|
||||
snapshot?: PersistedTeamLaunchSnapshot | null
|
||||
): string[] {
|
||||
const failed = new Set(failedNames);
|
||||
return expectedMembers
|
||||
.filter((memberName) => {
|
||||
if (failed.has(memberName)) {
|
||||
return false;
|
||||
}
|
||||
const { live, persisted } = this.getLaunchIncompleteMemberEvidence(
|
||||
run,
|
||||
snapshot,
|
||||
memberName
|
||||
);
|
||||
const hasEvidence = live !== undefined || persisted !== undefined;
|
||||
if (!hasEvidence) {
|
||||
return false;
|
||||
}
|
||||
const confirmed =
|
||||
live?.launchState === 'confirmed_alive' ||
|
||||
persisted?.launchState === 'confirmed_alive' ||
|
||||
live?.bootstrapConfirmed === true ||
|
||||
persisted?.bootstrapConfirmed === true;
|
||||
if (confirmed) {
|
||||
return false;
|
||||
}
|
||||
const skipped =
|
||||
live?.launchState === 'skipped_for_launch' ||
|
||||
persisted?.launchState === 'skipped_for_launch' ||
|
||||
live?.skippedForLaunch === true ||
|
||||
persisted?.skippedForLaunch === true;
|
||||
return !skipped;
|
||||
})
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private getLaunchIncompleteJoinedCount(
|
||||
run: ProvisioningRun,
|
||||
expectedMembers: readonly string[],
|
||||
namedMissingCount: number,
|
||||
launchSummary: {
|
||||
confirmedCount: number;
|
||||
},
|
||||
snapshot?: PersistedTeamLaunchSnapshot | null
|
||||
): number {
|
||||
const evidenceConfirmedCount = expectedMembers.filter((memberName) => {
|
||||
const { live, persisted } = this.getLaunchIncompleteMemberEvidence(run, snapshot, memberName);
|
||||
return (
|
||||
live?.launchState === 'confirmed_alive' ||
|
||||
persisted?.launchState === 'confirmed_alive' ||
|
||||
live?.bootstrapConfirmed === true ||
|
||||
persisted?.bootstrapConfirmed === true
|
||||
);
|
||||
}).length;
|
||||
const namedMissingUpperBound = expectedMembers.length - namedMissingCount;
|
||||
const rawJoinedCount =
|
||||
namedMissingCount > 0
|
||||
? Math.min(
|
||||
namedMissingUpperBound,
|
||||
Math.max(evidenceConfirmedCount, launchSummary.confirmedCount)
|
||||
)
|
||||
: Math.max(evidenceConfirmedCount, launchSummary.confirmedCount);
|
||||
return Math.max(0, Math.min(expectedMembers.length, rawJoinedCount));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Same-team native delivery dedup (Layer 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { renderLinkifiedText } from '@renderer/utils/linkifiedText';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
|
|
@ -431,9 +432,9 @@ export const ProvisioningProgressBlock = ({
|
|||
) : null}
|
||||
</div>
|
||||
{message ? (
|
||||
<p
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1.5 text-xs',
|
||||
'mt-1.5 whitespace-pre-wrap text-xs',
|
||||
isError || messageSeverity === 'error'
|
||||
? 'text-red-400'
|
||||
: messageSeverity === 'warning'
|
||||
|
|
@ -443,13 +444,16 @@ export const ProvisioningProgressBlock = ({
|
|||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
{renderLinkifiedText(message, {
|
||||
linkClassName: 'underline underline-offset-2 hover:text-[var(--color-accent)]',
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 px-2">
|
||||
<StepProgressBar
|
||||
steps={PROVISIONING_STEPS}
|
||||
currentIndex={currentStepIndex}
|
||||
active={loading}
|
||||
errorIndex={errorStepIndex}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ export interface StepProgressBarProps {
|
|||
steps: StepProgressBarStep[];
|
||||
/** 0-based index of the current step, -1 if not started */
|
||||
currentIndex: number;
|
||||
/** Whether the current step should show in-progress animations */
|
||||
active?: boolean;
|
||||
/** If set, this step shows a red error indicator instead of active/pending */
|
||||
errorIndex?: number;
|
||||
className?: string;
|
||||
|
|
@ -28,25 +30,33 @@ export interface StepProgressBarProps {
|
|||
export const StepProgressBar = ({
|
||||
steps,
|
||||
currentIndex,
|
||||
active = true,
|
||||
errorIndex,
|
||||
className,
|
||||
}: StepProgressBarProps): React.JSX.Element => {
|
||||
// Track which step just completed for jelly + flash animation
|
||||
const prevIndexRef = useRef(currentIndex);
|
||||
const [justCompletedIndex, setJustCompletedIndex] = useState<number | null>(null);
|
||||
const canAnimate = active && errorIndex === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevIndexRef.current;
|
||||
prevIndexRef.current = currentIndex;
|
||||
|
||||
// Animate the highest step that just became "done"
|
||||
if (currentIndex > prev && prev >= 0 && errorIndex === undefined) {
|
||||
const lastDoneIndex = Math.min(currentIndex - 1, steps.length - 1);
|
||||
setJustCompletedIndex(lastDoneIndex);
|
||||
const timer = setTimeout(() => setJustCompletedIndex(null), 500);
|
||||
return () => clearTimeout(timer);
|
||||
if (!canAnimate) {
|
||||
const clearTimer = window.setTimeout(() => setJustCompletedIndex(null), 0);
|
||||
return () => window.clearTimeout(clearTimer);
|
||||
}
|
||||
}, [currentIndex, errorIndex, steps.length]);
|
||||
|
||||
if (currentIndex > prev && prev >= 0) {
|
||||
const lastDoneIndex = Math.min(currentIndex - 1, steps.length - 1);
|
||||
const startTimer = window.setTimeout(() => setJustCompletedIndex(lastDoneIndex), 0);
|
||||
const clearTimer = window.setTimeout(() => setJustCompletedIndex(null), 500);
|
||||
return () => {
|
||||
window.clearTimeout(startTimer);
|
||||
window.clearTimeout(clearTimer);
|
||||
};
|
||||
}
|
||||
}, [canAnimate, currentIndex, steps.length]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-start justify-center', className)}>
|
||||
|
|
@ -55,11 +65,12 @@ export const StepProgressBar = ({
|
|||
const isDone = !isError && currentIndex >= 0 && index < currentIndex;
|
||||
const isCurrent = !isError && currentIndex >= 0 && index === currentIndex;
|
||||
const isLast = index === steps.length - 1;
|
||||
const isJustCompleted = justCompletedIndex === index;
|
||||
const isJustCompleted = canAnimate && justCompletedIndex === index;
|
||||
const isAnimatingCurrent = canAnimate && isCurrent;
|
||||
|
||||
// The connecting line between this step and the next
|
||||
const lineState: 'done' | 'active' | 'pending' =
|
||||
isDone && !isLast ? 'done' : isCurrent && !isLast ? 'active' : 'pending';
|
||||
isDone && !isLast ? 'done' : isAnimatingCurrent && !isLast ? 'active' : 'pending';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -69,7 +80,7 @@ export const StepProgressBar = ({
|
|||
>
|
||||
{/* Step circle + label column */}
|
||||
<div className="flex flex-col items-center" style={{ width: 56 }}>
|
||||
{/* Circle wrapper — holds flash overlay */}
|
||||
{/* Circle wrapper - holds flash overlay */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
{/* Green flash burst on completion */}
|
||||
{isJustCompleted && isDone && (
|
||||
|
|
@ -96,7 +107,7 @@ export const StepProgressBar = ({
|
|||
style={
|
||||
isJustCompleted && isDone
|
||||
? { animation: 'stepper-jelly 0.45s ease-out' }
|
||||
: isCurrent
|
||||
: isAnimatingCurrent
|
||||
? { animation: 'stepper-pulse-ring 2s ease-in-out infinite' }
|
||||
: undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { renderLinkifiedText } from '@renderer/utils/linkifiedText';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
buildMemberAvatarMap,
|
||||
|
|
@ -91,6 +92,34 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeLaunchFailureReason(value: string | undefined): string | null {
|
||||
const normalized = value
|
||||
?.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;
|
||||
}
|
||||
|
||||
function truncateLaunchFailureReason(value: string, maxLength = 220): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function getLaunchFailureLinkLabel(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname === 'openrouter.ai' && parsed.pathname === '/settings/credits') {
|
||||
return 'OpenRouter credits';
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export const MemberCard = memo(function MemberCard({
|
||||
member,
|
||||
memberColor,
|
||||
|
|
@ -244,6 +273,17 @@ export const MemberCard = memo(function MemberCard({
|
|||
spawnEntry?.skippedForLaunch === true;
|
||||
const showFailedLaunchBadge = !isRemoved && isFailedLaunch;
|
||||
const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch;
|
||||
const rawLaunchFailureReason =
|
||||
spawnError ??
|
||||
spawnEntry?.hardFailureReason ??
|
||||
spawnEntry?.runtimeDiagnostic ??
|
||||
spawnEntry?.error;
|
||||
const launchFailureReason = showFailedLaunchBadge
|
||||
? normalizeLaunchFailureReason(rawLaunchFailureReason)
|
||||
: null;
|
||||
const displayedLaunchFailureReason = launchFailureReason
|
||||
? truncateLaunchFailureReason(launchFailureReason)
|
||||
: null;
|
||||
const hasLiveLaunchControls =
|
||||
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
|
||||
const hasRestartMemberControl =
|
||||
|
|
@ -451,6 +491,21 @@ export const MemberCard = memo(function MemberCard({
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{displayedLaunchFailureReason ? (
|
||||
<div
|
||||
data-testid="member-launch-failure-reason"
|
||||
className="mt-1 min-w-0 text-[10px] font-medium leading-snug text-red-300/90"
|
||||
title={rawLaunchFailureReason}
|
||||
>
|
||||
<span className="line-clamp-2 break-words">
|
||||
{renderLinkifiedText(displayedLaunchFailureReason, {
|
||||
linkClassName: 'underline underline-offset-2 hover:text-red-200',
|
||||
stopPropagation: true,
|
||||
getLinkLabel: getLaunchFailureLinkLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showLaunchBadge ? (
|
||||
<span
|
||||
|
|
|
|||
105
src/renderer/utils/linkifiedText.tsx
Normal file
105
src/renderer/utils/linkifiedText.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { api } from '@renderer/api';
|
||||
|
||||
import type { MouseEvent, ReactElement, ReactNode } from 'react';
|
||||
|
||||
export interface LinkifiedTextOptions {
|
||||
linkClassName?: string;
|
||||
stopPropagation?: boolean;
|
||||
getLinkLabel?: (url: string) => string;
|
||||
}
|
||||
|
||||
function findNextHttpUrlStart(message: string, fromIndex: number): number {
|
||||
const httpIndex = message.indexOf('http://', fromIndex);
|
||||
const httpsIndex = message.indexOf('https://', fromIndex);
|
||||
if (httpIndex === -1) {
|
||||
return httpsIndex;
|
||||
}
|
||||
if (httpsIndex === -1) {
|
||||
return httpIndex;
|
||||
}
|
||||
return Math.min(httpIndex, httpsIndex);
|
||||
}
|
||||
|
||||
function isUrlTerminatingChar(char: string): boolean {
|
||||
return char.trim() === '' || char === '<' || char === '>' || char === ')' || char === ']';
|
||||
}
|
||||
|
||||
function findHttpUrlEnd(message: string, fromIndex: number): number {
|
||||
let end = fromIndex;
|
||||
while (end < message.length) {
|
||||
const char = message[end];
|
||||
if (!char || isUrlTerminatingChar(char)) {
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
function splitUrlTrailingPunctuation(rawUrl: string): { url: string; trailing: string } {
|
||||
let url = rawUrl;
|
||||
let trailing = '';
|
||||
while (/[.,;:!?]$/.test(url)) {
|
||||
trailing = `${url[url.length - 1]}${trailing}`;
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
return { url, trailing };
|
||||
}
|
||||
|
||||
function isLinkableHttpUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname !== '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderLinkifiedText(
|
||||
message: string,
|
||||
options: LinkifiedTextOptions = {}
|
||||
): ReactElement {
|
||||
const nodes: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
while (lastIndex < message.length) {
|
||||
const start = findNextHttpUrlStart(message, lastIndex);
|
||||
if (start === -1) {
|
||||
break;
|
||||
}
|
||||
if (start > lastIndex) {
|
||||
nodes.push(message.slice(lastIndex, start));
|
||||
}
|
||||
const end = findHttpUrlEnd(message, start);
|
||||
const rawUrl = message.slice(start, end);
|
||||
const { url, trailing } = splitUrlTrailingPunctuation(rawUrl);
|
||||
if (!isLinkableHttpUrl(url)) {
|
||||
nodes.push(rawUrl);
|
||||
lastIndex = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
||||
event.preventDefault();
|
||||
if (options.stopPropagation === true) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
void api.openExternal(url);
|
||||
};
|
||||
|
||||
nodes.push(
|
||||
<a key={`${url}:${start}`} href={url} className={options.linkClassName} onClick={handleClick}>
|
||||
{options.getLinkLabel?.(url) ?? url}
|
||||
</a>
|
||||
);
|
||||
if (trailing) {
|
||||
nodes.push(trailing);
|
||||
}
|
||||
lastIndex = end;
|
||||
}
|
||||
|
||||
if (lastIndex < message.length) {
|
||||
nodes.push(message.slice(lastIndex));
|
||||
}
|
||||
return <span>{nodes.length > 0 ? nodes : message}</span>;
|
||||
}
|
||||
|
|
@ -4,13 +4,13 @@ import {
|
|||
getLaunchJoinMilestonesFromMembers,
|
||||
getLaunchJoinState,
|
||||
} from '@renderer/components/team/provisioningSteps';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import type {
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
TeamProvisioningProgress,
|
||||
} from '@shared/types';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
type MemberSpawnStatusCollection =
|
||||
| Record<string, MemberSpawnStatusEntry>
|
||||
|
|
@ -574,18 +574,9 @@ function buildFailedSpawnPanelMessage(
|
|||
}
|
||||
if (failedSpawnDetails.length === 1) {
|
||||
const [failed] = failedSpawnDetails;
|
||||
return failed.reason
|
||||
? `${failed.name} failed to start - ${normalizeFailureReason(failed.reason)}`
|
||||
: `${failed.name} failed to start`;
|
||||
return `${failed.name} failed to start`;
|
||||
}
|
||||
const listedFailures = failedSpawnDetails
|
||||
.slice(0, 2)
|
||||
.map((failed) =>
|
||||
failed.reason ? `${failed.name} - ${normalizeFailureReason(failed.reason)}` : failed.name
|
||||
)
|
||||
.join('; ');
|
||||
const remainingCount = failedSpawnDetails.length - Math.min(failedSpawnDetails.length, 2);
|
||||
return `Failed teammates: ${listedFailures}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`;
|
||||
return `${failedSpawnDetails.length} teammates failed to start`;
|
||||
}
|
||||
|
||||
function buildFailedSpawnCompactDetail(
|
||||
|
|
|
|||
727
test/main/services/discovery/ProjectScanner.sessionIndex.test.ts
Normal file
727
test/main/services/discovery/ProjectScanner.sessionIndex.test.ts
Normal file
|
|
@ -0,0 +1,727 @@
|
|||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner';
|
||||
import { SessionMetadataIndex } from '../../../../src/main/services/discovery/SessionMetadataIndex';
|
||||
import { subprojectRegistry } from '../../../../src/main/services/discovery/SubprojectRegistry';
|
||||
import type { FileSystemProvider } from '../../../../src/main/services/infrastructure/FileSystemProvider';
|
||||
|
||||
function createSessionLine(content: string, timestamp = '2026-01-01T00:00:00.000Z'): string {
|
||||
return JSON.stringify({
|
||||
uuid: crypto.randomUUID(),
|
||||
type: 'user',
|
||||
message: { role: 'user', content },
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
function createNoiseLine(): string {
|
||||
return JSON.stringify({
|
||||
uuid: crypto.randomUUID(),
|
||||
type: 'system',
|
||||
content: 'noise',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
}
|
||||
|
||||
function createSessionLineWithCwd(content: string, cwd: string): string {
|
||||
return JSON.stringify({
|
||||
uuid: crypto.randomUUID(),
|
||||
type: 'user',
|
||||
cwd,
|
||||
message: { role: 'user', content },
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
}
|
||||
|
||||
function createSshLikeLocalProvider(): FileSystemProvider {
|
||||
return {
|
||||
type: 'ssh',
|
||||
exists: (filePath: string) => Promise.resolve(fs.existsSync(filePath)),
|
||||
readFile: async (filePath: string, encoding: BufferEncoding = 'utf8') =>
|
||||
fs.promises.readFile(filePath, encoding),
|
||||
stat: async (filePath: string) => {
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
return {
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
birthtimeMs: stats.birthtimeMs,
|
||||
isFile: () => stats.isFile(),
|
||||
isDirectory: () => stats.isDirectory(),
|
||||
};
|
||||
},
|
||||
readdir: async (dirPath: string) => {
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
return Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(dirPath, entry.name);
|
||||
const stats = await fs.promises.stat(entryPath);
|
||||
return {
|
||||
name: entry.name,
|
||||
isFile: () => entry.isFile(),
|
||||
isDirectory: () => entry.isDirectory(),
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
birthtimeMs: stats.birthtimeMs,
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
createReadStream: (filePath: string, opts?: { start?: number; encoding?: BufferEncoding }) =>
|
||||
fs.createReadStream(filePath, opts),
|
||||
dispose: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createReadStreamFailingLocalProvider(): FileSystemProvider {
|
||||
return {
|
||||
...createSshLikeLocalProvider(),
|
||||
type: 'local',
|
||||
createReadStream: () => {
|
||||
throw new Error('session body should not be read when a fresh index entry exists');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readIndexSessions(
|
||||
indexDir: string,
|
||||
projectDir: string
|
||||
): Record<string, Record<string, unknown>> {
|
||||
const raw = fs.readFileSync(SessionMetadataIndex.getIndexPath(indexDir, projectDir), 'utf8');
|
||||
const parsed = JSON.parse(raw) as { sessions?: Record<string, Record<string, unknown>> };
|
||||
return parsed.sessions ?? {};
|
||||
}
|
||||
|
||||
function sortStrings(values: string[]): string[] {
|
||||
return [...values].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | 'timeout'> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<'timeout'>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve('timeout'), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProjectScanner session metadata index', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
subprojectRegistry.clear();
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
function createFixture(): {
|
||||
projectsDir: string;
|
||||
indexDir: string;
|
||||
projectId: string;
|
||||
projectDir: string;
|
||||
sessionPath: string;
|
||||
} {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-index-'));
|
||||
tempDirs.push(rootDir);
|
||||
|
||||
const projectsDir = path.join(rootDir, 'projects');
|
||||
const indexDir = path.join(rootDir, 'session-index');
|
||||
const projectId = '-Users-test-indexed-project';
|
||||
const projectDir = path.join(projectsDir, projectId);
|
||||
const sessionPath = path.join(projectDir, 'session-1.jsonl');
|
||||
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
return {
|
||||
projectsDir,
|
||||
indexDir,
|
||||
projectId,
|
||||
projectDir,
|
||||
sessionPath,
|
||||
};
|
||||
}
|
||||
|
||||
it('does not serve stale indexed metadata after a session file changes', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
|
||||
fs.writeFileSync(sessionPath, `${createSessionLine('old indexed title')}\n`, 'utf8');
|
||||
|
||||
const firstScanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const firstSessions = await firstScanner.listSessions(projectId);
|
||||
expect(firstSessions).toHaveLength(1);
|
||||
expect(firstSessions[0].firstMessage).toBe('old indexed title');
|
||||
|
||||
await firstScanner.flushSessionMetadataIndexForTesting();
|
||||
const indexPath = SessionMetadataIndex.getIndexPath(indexDir, projectDir);
|
||||
expect(fs.existsSync(indexPath)).toBe(true);
|
||||
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
`${createSessionLine('new indexed title with different size')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
const future = new Date('2026-01-01T00:01:00.000Z');
|
||||
fs.utimesSync(sessionPath, future, future);
|
||||
|
||||
const secondScanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const secondSessions = await secondScanner.listSessions(projectId);
|
||||
expect(secondSessions).toHaveLength(1);
|
||||
expect(secondSessions[0].firstMessage).toBe('new indexed title with different size');
|
||||
});
|
||||
|
||||
it('falls back to live parsing when the persisted index is corrupt', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(sessionPath, `${createSessionLine('live title')}\n`, 'utf8');
|
||||
|
||||
fs.mkdirSync(indexDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
SessionMetadataIndex.getIndexPath(indexDir, projectDir),
|
||||
'{not valid json',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const scanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const sessions = await scanner.listSessions(projectId);
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].firstMessage).toBe('live title');
|
||||
});
|
||||
|
||||
it('ignores malformed indexed metadata and reparses the live session file', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(sessionPath, `${createSessionLine('live metadata title')}\n`, 'utf8');
|
||||
const stats = fs.statSync(sessionPath);
|
||||
|
||||
fs.mkdirSync(indexDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
SessionMetadataIndex.getIndexPath(indexDir, projectDir),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
projectStorageDir: projectDir,
|
||||
updatedAt: Date.now(),
|
||||
sessions: {
|
||||
[sessionPath]: {
|
||||
sessionId: 'session-1',
|
||||
filePath: sessionPath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
birthtimeMs: stats.birthtimeMs,
|
||||
hasContent: true,
|
||||
metadata: {
|
||||
firstUserMessage: { text: 42, timestamp: 'bad' },
|
||||
messageCount: 'bad',
|
||||
isOngoing: 'bad',
|
||||
gitBranch: null,
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const scanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const sessions = await scanner.listSessions(projectId);
|
||||
await scanner.flushSessionMetadataIndexForTesting();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].firstMessage).toBe('live metadata title');
|
||||
expect(
|
||||
(
|
||||
readIndexSessions(indexDir, projectDir)[sessionPath].metadata as {
|
||||
firstUserMessage?: { text?: string };
|
||||
}
|
||||
).firstUserMessage?.text
|
||||
).toBe('live metadata title');
|
||||
});
|
||||
|
||||
it('does not trust content presence from an entry with malformed metadata', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
`${createSessionLine('visible despite corrupt index')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
const stats = fs.statSync(sessionPath);
|
||||
|
||||
fs.mkdirSync(indexDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
SessionMetadataIndex.getIndexPath(indexDir, projectDir),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
projectStorageDir: projectDir,
|
||||
updatedAt: Date.now(),
|
||||
sessions: {
|
||||
[sessionPath]: {
|
||||
sessionId: 'session-1',
|
||||
filePath: sessionPath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
birthtimeMs: stats.birthtimeMs,
|
||||
hasContent: false,
|
||||
metadata: {
|
||||
firstUserMessage: { text: 42, timestamp: 'bad' },
|
||||
messageCount: 'bad',
|
||||
isOngoing: 'bad',
|
||||
gitBranch: null,
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const scanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const sessions = await scanner.listSessions(projectId);
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].firstMessage).toBe('visible despite corrupt index');
|
||||
});
|
||||
|
||||
it('does not trust content presence when the indexed session id mismatches the file', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(sessionPath, `${createSessionLine('visible despite id mismatch')}\n`, 'utf8');
|
||||
const stats = fs.statSync(sessionPath);
|
||||
|
||||
fs.mkdirSync(indexDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
SessionMetadataIndex.getIndexPath(indexDir, projectDir),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
projectStorageDir: projectDir,
|
||||
updatedAt: Date.now(),
|
||||
sessions: {
|
||||
[sessionPath]: {
|
||||
sessionId: 'different-session',
|
||||
filePath: sessionPath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
birthtimeMs: stats.birthtimeMs,
|
||||
hasContent: false,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const scanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const sessions = await scanner.listSessions(projectId);
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].firstMessage).toBe('visible despite id mismatch');
|
||||
});
|
||||
|
||||
it('does not trust content presence from an entry with invalid numeric signature fields', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(
|
||||
sessionPath,
|
||||
`${createSessionLine('visible despite invalid signature')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
const stats = fs.statSync(sessionPath);
|
||||
|
||||
fs.mkdirSync(indexDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
SessionMetadataIndex.getIndexPath(indexDir, projectDir),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
projectStorageDir: projectDir,
|
||||
updatedAt: Date.now(),
|
||||
sessions: {
|
||||
[sessionPath]: {
|
||||
sessionId: 'session-1',
|
||||
filePath: sessionPath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
birthtimeMs: -1,
|
||||
hasContent: false,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const scanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const sessions = await scanner.listSessions(projectId);
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].firstMessage).toBe('visible despite invalid signature');
|
||||
});
|
||||
|
||||
it('does not hide a session when a stale no-content index entry exists', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(sessionPath, `${createNoiseLine()}\n`, 'utf8');
|
||||
|
||||
const firstScanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const noiseSessions = await firstScanner.listSessions(projectId);
|
||||
expect(noiseSessions).toHaveLength(0);
|
||||
await firstScanner.flushSessionMetadataIndexForTesting();
|
||||
expect(readIndexSessions(indexDir, projectDir)[sessionPath].hasContent).toBe(false);
|
||||
|
||||
fs.writeFileSync(sessionPath, `${createSessionLine('visible after stale false')}\n`, 'utf8');
|
||||
const future = new Date('2026-01-01T00:02:00.000Z');
|
||||
fs.utimesSync(sessionPath, future, future);
|
||||
|
||||
const secondScanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const liveSessions = await secondScanner.listSessions(projectId);
|
||||
|
||||
expect(liveSessions).toHaveLength(1);
|
||||
expect(liveSessions[0].firstMessage).toBe('visible after stale false');
|
||||
});
|
||||
|
||||
it('ignores indexed metadata containing non-finite numeric fields', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(sessionPath, `${createSessionLine('live finite numeric title')}\n`, 'utf8');
|
||||
const stats = fs.statSync(sessionPath);
|
||||
|
||||
fs.mkdirSync(indexDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
SessionMetadataIndex.getIndexPath(indexDir, projectDir),
|
||||
`{
|
||||
"schemaVersion": 1,
|
||||
"projectStorageDir": ${JSON.stringify(projectDir)},
|
||||
"updatedAt": ${Date.now()},
|
||||
"sessions": {
|
||||
${JSON.stringify(sessionPath)}: {
|
||||
"sessionId": "session-1",
|
||||
"filePath": ${JSON.stringify(sessionPath)},
|
||||
"mtimeMs": ${stats.mtimeMs},
|
||||
"size": ${stats.size},
|
||||
"birthtimeMs": ${stats.birthtimeMs},
|
||||
"hasContent": true,
|
||||
"metadata": {
|
||||
"firstUserMessage": {
|
||||
"text": "indexed title with corrupt numeric field",
|
||||
"timestamp": "2026-01-01T00:00:00.000Z"
|
||||
},
|
||||
"messageCount": 1,
|
||||
"isOngoing": false,
|
||||
"gitBranch": null,
|
||||
"model": null,
|
||||
"contextConsumption": 1e999
|
||||
},
|
||||
"updatedAt": ${Date.now()}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const scanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const sessions = await scanner.listSessions(projectId);
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].firstMessage).toBe('live finite numeric title');
|
||||
});
|
||||
|
||||
it('ignores indexed metadata with corrupt phase breakdown fields', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(sessionPath, `${createSessionLine('live phase breakdown title')}\n`, 'utf8');
|
||||
const stats = fs.statSync(sessionPath);
|
||||
|
||||
fs.mkdirSync(indexDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
SessionMetadataIndex.getIndexPath(indexDir, projectDir),
|
||||
`{
|
||||
"schemaVersion": 1,
|
||||
"projectStorageDir": ${JSON.stringify(projectDir)},
|
||||
"updatedAt": ${Date.now()},
|
||||
"sessions": {
|
||||
${JSON.stringify(sessionPath)}: {
|
||||
"sessionId": "session-1",
|
||||
"filePath": ${JSON.stringify(sessionPath)},
|
||||
"mtimeMs": ${stats.mtimeMs},
|
||||
"size": ${stats.size},
|
||||
"birthtimeMs": ${stats.birthtimeMs},
|
||||
"hasContent": true,
|
||||
"metadata": {
|
||||
"firstUserMessage": {
|
||||
"text": "indexed title with corrupt phase breakdown",
|
||||
"timestamp": "2026-01-01T00:00:00.000Z"
|
||||
},
|
||||
"messageCount": 1,
|
||||
"isOngoing": false,
|
||||
"gitBranch": null,
|
||||
"model": null,
|
||||
"contextConsumption": 10,
|
||||
"compactionCount": 1,
|
||||
"phaseBreakdown": [
|
||||
{
|
||||
"phaseNumber": 1,
|
||||
"contribution": 10,
|
||||
"peakTokens": 10,
|
||||
"postCompaction": 1e999
|
||||
}
|
||||
]
|
||||
},
|
||||
"updatedAt": ${Date.now()}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const scanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const sessions = await scanner.listSessions(projectId);
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].firstMessage).toBe('live phase breakdown title');
|
||||
});
|
||||
|
||||
it('does not retry forever when the index cannot be persisted', async () => {
|
||||
const { projectsDir, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(sessionPath, `${createSessionLine('best effort cache')}\n`, 'utf8');
|
||||
const stats = fs.statSync(sessionPath);
|
||||
const blockedIndexRoot = path.join(path.dirname(projectsDir), 'blocked-index-root');
|
||||
fs.writeFileSync(blockedIndexRoot, 'not a directory', 'utf8');
|
||||
|
||||
const index = new SessionMetadataIndex({
|
||||
rootDir: blockedIndexRoot,
|
||||
persistDelayMs: 0,
|
||||
});
|
||||
await index.setMetadata(
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
filePath: sessionPath,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
birthtimeMs: stats.birthtimeMs,
|
||||
},
|
||||
{
|
||||
firstUserMessage: {
|
||||
text: 'best effort cache',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
messageCount: 1,
|
||||
isOngoing: false,
|
||||
gitBranch: null,
|
||||
model: null,
|
||||
}
|
||||
);
|
||||
|
||||
const flushResult = await withTimeout(
|
||||
index.flushForTesting().then(() => 'resolved'),
|
||||
500
|
||||
);
|
||||
|
||||
expect(flushResult).toBe('resolved');
|
||||
expect(fs.existsSync(SessionMetadataIndex.getIndexPath(blockedIndexRoot, projectDir))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('persists content filtering and metadata across scanner instances for paginated listing', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir } = createFixture();
|
||||
const visiblePath = path.join(projectDir, 'session-visible.jsonl');
|
||||
const noisePath = path.join(projectDir, 'session-noise.jsonl');
|
||||
fs.writeFileSync(visiblePath, `${createSessionLine('visible title')}\n`, 'utf8');
|
||||
fs.writeFileSync(noisePath, `${createNoiseLine()}\n`, 'utf8');
|
||||
|
||||
const firstScanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const firstPage = await firstScanner.listSessionsPaginated(projectId, null, 10, {
|
||||
includeTotalCount: true,
|
||||
prefilterAll: true,
|
||||
metadataLevel: 'deep',
|
||||
});
|
||||
expect(firstPage.sessions.map((session) => session.id)).toEqual(['session-visible']);
|
||||
expect(firstPage.totalCount).toBe(1);
|
||||
|
||||
await firstScanner.flushSessionMetadataIndexForTesting();
|
||||
const indexedSessions = readIndexSessions(indexDir, projectDir);
|
||||
expect(sortStrings(Object.keys(indexedSessions))).toEqual(
|
||||
sortStrings([noisePath, visiblePath])
|
||||
);
|
||||
expect(indexedSessions[visiblePath].hasContent).toBe(true);
|
||||
expect(indexedSessions[noisePath].hasContent).toBe(false);
|
||||
expect(
|
||||
(
|
||||
indexedSessions[visiblePath].metadata as {
|
||||
firstUserMessage?: { text?: string };
|
||||
}
|
||||
).firstUserMessage?.text
|
||||
).toBe('visible title');
|
||||
|
||||
const secondScanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const secondPage = await secondScanner.listSessionsPaginated(projectId, null, 10, {
|
||||
includeTotalCount: true,
|
||||
prefilterAll: true,
|
||||
metadataLevel: 'deep',
|
||||
});
|
||||
expect(secondPage.sessions.map((session) => session.id)).toEqual(['session-visible']);
|
||||
expect(secondPage.sessions[0].firstMessage).toBe('visible title');
|
||||
expect(secondPage.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('serves fresh indexed listing data without reopening the session body', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(sessionPath, `${createSessionLine('fresh cached title')}\n`, 'utf8');
|
||||
|
||||
const firstScanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const firstSessions = await firstScanner.listSessions(projectId);
|
||||
expect(firstSessions).toHaveLength(1);
|
||||
await firstScanner.flushSessionMetadataIndexForTesting();
|
||||
expect(readIndexSessions(indexDir, projectDir)[sessionPath].hasContent).toBe(true);
|
||||
|
||||
const secondScanner = new ProjectScanner(
|
||||
projectsDir,
|
||||
undefined,
|
||||
createReadStreamFailingLocalProvider(),
|
||||
{
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
}
|
||||
);
|
||||
const secondSessions = await secondScanner.listSessions(projectId);
|
||||
|
||||
expect(secondSessions).toHaveLength(1);
|
||||
expect(secondSessions[0].firstMessage).toBe('fresh cached title');
|
||||
});
|
||||
|
||||
it('does not persist a local session index for ssh filesystem providers', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir, sessionPath } = createFixture();
|
||||
fs.writeFileSync(sessionPath, `${createSessionLine('ssh provider title')}\n`, 'utf8');
|
||||
|
||||
const scanner = new ProjectScanner(projectsDir, undefined, createSshLikeLocalProvider(), {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const sessions = await scanner.listSessions(projectId);
|
||||
await scanner.flushSessionMetadataIndexForTesting();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].firstMessage).toBe('ssh provider title');
|
||||
expect(fs.existsSync(SessionMetadataIndex.getIndexPath(indexDir, projectDir))).toBe(false);
|
||||
});
|
||||
|
||||
it('prunes deleted session entries from the persisted index after relisting', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir } = createFixture();
|
||||
const keepPath = path.join(projectDir, 'session-keep.jsonl');
|
||||
const deletePath = path.join(projectDir, 'session-delete.jsonl');
|
||||
fs.writeFileSync(keepPath, `${createSessionLine('keep title')}\n`, 'utf8');
|
||||
fs.writeFileSync(deletePath, `${createSessionLine('delete title')}\n`, 'utf8');
|
||||
|
||||
const firstScanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
await firstScanner.listSessions(projectId);
|
||||
await firstScanner.flushSessionMetadataIndexForTesting();
|
||||
expect(sortStrings(Object.keys(readIndexSessions(indexDir, projectDir)))).toEqual(
|
||||
sortStrings([deletePath, keepPath])
|
||||
);
|
||||
|
||||
fs.rmSync(deletePath);
|
||||
|
||||
const secondScanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const sessions = await secondScanner.listSessions(projectId);
|
||||
await secondScanner.flushSessionMetadataIndexForTesting();
|
||||
|
||||
expect(sessions.map((session) => session.id)).toEqual(['session-keep']);
|
||||
expect(Object.keys(readIndexSessions(indexDir, projectDir))).toEqual([keepPath]);
|
||||
});
|
||||
|
||||
it('does not prune sibling composite-subproject entries when listing one subproject', async () => {
|
||||
const { projectsDir, indexDir, projectId, projectDir } = createFixture();
|
||||
const sessionAPath = path.join(projectDir, 'session-a.jsonl');
|
||||
const sessionBPath = path.join(projectDir, 'session-b.jsonl');
|
||||
fs.writeFileSync(
|
||||
sessionAPath,
|
||||
`${createSessionLineWithCwd('title a', '/Users/test/indexed-project-a')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
sessionBPath,
|
||||
`${createSessionLineWithCwd('title b', '/Users/test/indexed-project-b')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const scanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
const projects = await scanner.scan();
|
||||
const compositeProjects = projects
|
||||
.filter((project) => project.id.startsWith(`${projectId}::`))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
expect(compositeProjects).toHaveLength(2);
|
||||
|
||||
await scanner.listSessions(compositeProjects[0].id);
|
||||
await scanner.listSessions(compositeProjects[1].id);
|
||||
await scanner.flushSessionMetadataIndexForTesting();
|
||||
expect(sortStrings(Object.keys(readIndexSessions(indexDir, projectDir)))).toEqual(
|
||||
sortStrings([sessionAPath, sessionBPath])
|
||||
);
|
||||
|
||||
const freshScanner = new ProjectScanner(projectsDir, undefined, undefined, {
|
||||
sessionIndexDir: indexDir,
|
||||
sessionIndexPersistDelayMs: 0,
|
||||
});
|
||||
await freshScanner.scan();
|
||||
await freshScanner.listSessions(compositeProjects[0].id);
|
||||
await freshScanner.flushSessionMetadataIndexForTesting();
|
||||
|
||||
expect(sortStrings(Object.keys(readIndexSessions(indexDir, projectDir)))).toEqual(
|
||||
sortStrings([sessionAPath, sessionBPath])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -585,6 +585,113 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('team launch notifications', () => {
|
||||
it('uses live member evidence instead of stale summary for incomplete launch copy', async () => {
|
||||
const { NotificationManager } =
|
||||
await import('@main/services/infrastructure/NotificationManager');
|
||||
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
|
||||
NotificationManager.setInstance({ addTeamNotification } as never);
|
||||
|
||||
try {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = {
|
||||
runId: 'run-relay-works-18',
|
||||
teamName: 'relay-works-18',
|
||||
isLaunch: true,
|
||||
request: {
|
||||
cwd: tempClaudeRoot,
|
||||
displayName: 'relay-works-18',
|
||||
},
|
||||
expectedMembers: ['bob', 'jack', 'alice', 'tom'],
|
||||
allEffectiveMembers: [
|
||||
{ name: 'bob' },
|
||||
{ name: 'jack' },
|
||||
{ name: 'alice' },
|
||||
{ name: 'tom' },
|
||||
],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
}),
|
||||
],
|
||||
[
|
||||
'jack',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
}),
|
||||
],
|
||||
[
|
||||
'alice',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Insufficient credits',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'tom',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
};
|
||||
const staleSnapshot = {
|
||||
expectedMembers: ['bob', 'jack', 'alice', 'tom'],
|
||||
members: Object.fromEntries(
|
||||
['bob', 'jack', 'alice', 'tom'].map((name) => [
|
||||
name,
|
||||
{
|
||||
name,
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-13T10:00:00.000Z',
|
||||
},
|
||||
])
|
||||
),
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 4,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
await (svc as any).fireTeamLaunchIncompleteNotification(
|
||||
run,
|
||||
[{ name: 'alice' }],
|
||||
staleSnapshot.summary,
|
||||
staleSnapshot
|
||||
);
|
||||
} finally {
|
||||
NotificationManager.resetInstance();
|
||||
}
|
||||
|
||||
expect(addTeamNotification).toHaveBeenCalledTimes(1);
|
||||
const payload = addTeamNotification.mock.calls[0]![0] as { body: string };
|
||||
expect(payload.body).toBe('2/4 joined · failed: @alice · still joining: @tom');
|
||||
expect(payload.body).not.toContain('0/4');
|
||||
expect(payload.body).not.toContain('did not join');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClaudeLogs', () => {
|
||||
it('retains the last logs after cleanupRun removes the live run', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,17 @@ import React, { act } from 'react';
|
|||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
openExternal: vi.fn(),
|
||||
stepperProps: [] as { active?: boolean }[],
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
openExternal: hoisted.openExternal,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) =>
|
||||
React.createElement('button', { type: 'button', onClick }, children),
|
||||
|
|
@ -17,7 +28,14 @@ vi.mock('@renderer/components/team/CliLogsRichView', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/StepProgressBar', () => ({
|
||||
StepProgressBar: () => React.createElement('div', null, 'step-progress'),
|
||||
StepProgressBar: (props: { active?: boolean }) => {
|
||||
hoisted.stepperProps.push(props);
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-stepper-active': props.active ? 'true' : 'false' },
|
||||
'step-progress'
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
|
|
@ -40,6 +58,8 @@ import { ProvisioningProgressBlock } from '@renderer/components/team/Provisionin
|
|||
describe('ProvisioningProgressBlock', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
hoisted.openExternal.mockReset();
|
||||
hoisted.stepperProps = [];
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
|
@ -69,6 +89,9 @@ describe('ProvisioningProgressBlock', () => {
|
|||
expect(host.textContent).toContain('CLI logs');
|
||||
expect(host.textContent).not.toContain('streamed output');
|
||||
expect(host.textContent).not.toContain('logs:tail line');
|
||||
expect(host.querySelector('[data-stepper-active]')?.getAttribute('data-stepper-active')).toBe(
|
||||
'true'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -257,4 +280,48 @@ describe('ProvisioningProgressBlock', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders multi-line status messages and opens links externally', 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(ProvisioningProgressBlock, {
|
||||
title: 'Launch details',
|
||||
message:
|
||||
'Failed teammates:\n- alice - Insufficient credits. Add more using https://openrouter.ai/settings/credits\n- tom - Insufficient credits. Add more using https://openrouter.ai/settings/credits',
|
||||
messageSeverity: 'warning',
|
||||
currentStepIndex: 2,
|
||||
loading: false,
|
||||
defaultLiveOutputOpen: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Failed teammates:');
|
||||
expect(host.textContent).toContain('alice - Insufficient credits');
|
||||
expect(host.textContent).toContain('tom - Insufficient credits');
|
||||
expect(host.querySelector('[data-stepper-active]')?.getAttribute('data-stepper-active')).toBe(
|
||||
'false'
|
||||
);
|
||||
|
||||
const links = host.querySelectorAll('a[href="https://openrouter.ai/settings/credits"]');
|
||||
expect(links).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
links[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.openExternal).toHaveBeenCalledWith('https://openrouter.ai/settings/credits');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
151
test/renderer/components/team/StepProgressBar.test.tsx
Normal file
151
test/renderer/components/team/StepProgressBar.test.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
|
||||
|
||||
import type { StepProgressBarStep } from '@renderer/components/team/StepProgressBar';
|
||||
import type { Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
||||
return {
|
||||
Check: Icon,
|
||||
X: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
const STEPS: StepProgressBarStep[] = [
|
||||
{ key: 'starting', label: 'Starting' },
|
||||
{ key: 'setup', label: 'Team setup' },
|
||||
{ key: 'joining', label: 'Members joining' },
|
||||
{ key: 'finalizing', label: 'Finalizing' },
|
||||
];
|
||||
|
||||
function hasInlineAnimation(host: HTMLElement, animationName: string): boolean {
|
||||
return Array.from(host.querySelectorAll<HTMLElement>('[style]')).some((node) =>
|
||||
(node.getAttribute('style') ?? '').includes(animationName)
|
||||
);
|
||||
}
|
||||
|
||||
function waitForTimerTick(): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function renderStepper(
|
||||
props: React.ComponentProps<typeof StepProgressBar>
|
||||
): Promise<{ host: HTMLDivElement; root: Root }> {
|
||||
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(StepProgressBar, props));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe('StepProgressBar', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('animates the current step and next connector while active', async () => {
|
||||
const { host, root } = await renderStepper({
|
||||
steps: STEPS,
|
||||
currentIndex: 2,
|
||||
active: true,
|
||||
});
|
||||
|
||||
expect(hasInlineAnimation(host, 'stepper-pulse-ring')).toBe(true);
|
||||
expect(hasInlineAnimation(host, 'stepper-line-sweep')).toBe(true);
|
||||
expect(host.textContent).toContain('Members joining');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the current marker but stops progress animations when settled', async () => {
|
||||
const { host, root } = await renderStepper({
|
||||
steps: STEPS,
|
||||
currentIndex: 2,
|
||||
active: false,
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('3');
|
||||
expect(host.textContent).toContain('Members joining');
|
||||
expect(hasInlineAnimation(host, 'stepper-pulse-ring')).toBe(false);
|
||||
expect(hasInlineAnimation(host, 'stepper-line-sweep')).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('plays completion animations when an active launch advances steps', async () => {
|
||||
const { host, root } = await renderStepper({
|
||||
steps: STEPS,
|
||||
currentIndex: 1,
|
||||
active: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(StepProgressBar, {
|
||||
steps: STEPS,
|
||||
currentIndex: 2,
|
||||
active: true,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await waitForTimerTick();
|
||||
});
|
||||
|
||||
expect(hasInlineAnimation(host, 'stepper-flash')).toBe(true);
|
||||
expect(hasInlineAnimation(host, 'stepper-jelly')).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not play completion animations for an inactive terminal update', async () => {
|
||||
const { host, root } = await renderStepper({
|
||||
steps: STEPS,
|
||||
currentIndex: 1,
|
||||
active: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(StepProgressBar, {
|
||||
steps: STEPS,
|
||||
currentIndex: 2,
|
||||
active: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hasInlineAnimation(host, 'stepper-flash')).toBe(false);
|
||||
expect(hasInlineAnimation(host, 'stepper-jelly')).toBe(false);
|
||||
expect(hasInlineAnimation(host, 'stepper-pulse-ring')).toBe(false);
|
||||
expect(hasInlineAnimation(host, 'stepper-line-sweep')).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
openExternal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
openExternal: hoisted.openExternal,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({
|
||||
children,
|
||||
|
|
@ -86,6 +96,7 @@ const skippedSpawnEntry: MemberSpawnStatusEntry = {
|
|||
describe('MemberCard starting-state visuals', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
hoisted.openExternal.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
|
@ -772,6 +783,60 @@ describe('MemberCard starting-state visuals', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows a compact failed launch reason on the member row with clickable links', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const reason =
|
||||
'Latest assistant message msg_df2d6414f0016Bn0Pc0QJbo5sU failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits';
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'error',
|
||||
spawnLaunchState: 'failed_to_start',
|
||||
spawnRuntimeAlive: false,
|
||||
spawnError: reason,
|
||||
spawnEntry: {
|
||||
...failedSpawnEntry,
|
||||
hardFailureReason: reason,
|
||||
runtimeDiagnostic: reason,
|
||||
},
|
||||
onRestartMember: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const failureReason = host.querySelector('[data-testid="member-launch-failure-reason"]');
|
||||
expect(failureReason?.textContent).toContain('Insufficient credits');
|
||||
expect(failureReason?.textContent).toContain('OpenRouter credits');
|
||||
expect(failureReason?.textContent).not.toContain('Latest assistant message');
|
||||
expect(failureReason?.textContent).not.toContain('msg_df2d6414');
|
||||
|
||||
const link = failureReason?.querySelector(
|
||||
'a[href="https://openrouter.ai/settings/credits"]'
|
||||
) as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.openExternal).toHaveBeenCalledWith('https://openrouter.ai/settings/credits');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Relaunch OpenCode for registered-only OpenCode teammates', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { GraphProvisioningHud } from '@features/agent-graph/renderer/ui/GraphProvisioningHud';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
stepperProps: [] as { active?: boolean }[],
|
||||
}));
|
||||
|
||||
const hookState = {
|
||||
presentation: null as
|
||||
| {
|
||||
presentation: null as {
|
||||
isActive: boolean;
|
||||
isFailed: boolean;
|
||||
hasMembersStillJoining: boolean;
|
||||
|
|
@ -16,8 +19,7 @@ const hookState = {
|
|||
compactDetail?: string | null;
|
||||
currentStepIndex: number;
|
||||
progress: { runId: string };
|
||||
}
|
||||
| null,
|
||||
} | null,
|
||||
runInstanceKey: 'team:run-1:2026-04-13T10:00:00.000Z',
|
||||
};
|
||||
|
||||
|
|
@ -26,19 +28,26 @@ vi.mock('@renderer/components/team/useTeamProvisioningPresentation', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/badge', () => ({
|
||||
Badge: ({ children }: { children: React.ReactNode }) => React.createElement('span', null, children),
|
||||
Badge: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('span', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/StepProgressBar', () => ({
|
||||
StepProgressBar: () => React.createElement('div', { 'data-testid': 'stepper' }, 'stepper'),
|
||||
StepProgressBar: (props: { active?: boolean }) => {
|
||||
hoisted.stepperProps.push(props);
|
||||
return React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'stepper',
|
||||
'data-stepper-active': props.active ? 'true' : 'false',
|
||||
},
|
||||
'stepper'
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/TeamProvisioningPanel', () => ({
|
||||
TeamProvisioningPanel: ({
|
||||
defaultLogsOpen,
|
||||
}: {
|
||||
defaultLogsOpen?: boolean;
|
||||
}) =>
|
||||
TeamProvisioningPanel: ({ defaultLogsOpen }: { defaultLogsOpen?: boolean }) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'panel', 'data-default-logs-open': defaultLogsOpen ? 'true' : 'false' },
|
||||
|
|
@ -51,6 +60,7 @@ describe('GraphProvisioningHud', () => {
|
|||
document.body.innerHTML = '';
|
||||
hookState.presentation = null;
|
||||
hookState.runInstanceKey = 'team:run-1:2026-04-13T10:00:00.000Z';
|
||||
hoisted.stepperProps = [];
|
||||
});
|
||||
|
||||
it('hides the graph launch hud once provisioning is ready', async () => {
|
||||
|
|
@ -119,6 +129,9 @@ describe('GraphProvisioningHud', () => {
|
|||
|
||||
const openButton = host.querySelector('button[aria-label]');
|
||||
expect(openButton).not.toBeNull();
|
||||
expect(host.querySelector('[data-testid="stepper"]')?.getAttribute('data-stepper-active')).toBe(
|
||||
'true'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
|
@ -126,8 +139,45 @@ describe('GraphProvisioningHud', () => {
|
|||
});
|
||||
|
||||
expect(document.body.textContent).toContain('provisioning-panel');
|
||||
expect(document.body.querySelector('[data-testid="panel"]')?.getAttribute('data-default-logs-open')).toBe(
|
||||
'true'
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="panel"]')?.getAttribute('data-default-logs-open')
|
||||
).toBe('true');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the failed graph hud without active stepper animation', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
hookState.presentation = {
|
||||
isActive: false,
|
||||
isFailed: true,
|
||||
hasMembersStillJoining: false,
|
||||
failedSpawnCount: 1,
|
||||
compactTone: 'error',
|
||||
compactTitle: 'Launch failed',
|
||||
compactDetail: 'alice failed to start',
|
||||
currentStepIndex: 2,
|
||||
progress: { runId: 'run-4' },
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(GraphProvisioningHud, {
|
||||
teamName: 'northstar-core',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="stepper"]')?.getAttribute('data-stepper-active')).toBe(
|
||||
'false'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -83,8 +83,7 @@ describe('buildTeamProvisioningPresentation', () => {
|
|||
memberSpawnSnapshot: undefined,
|
||||
});
|
||||
|
||||
expect(presentation?.panelMessage).toContain('jack failed to start');
|
||||
expect(presentation?.panelMessage).toContain('gpt-5.2-codex');
|
||||
expect(presentation?.panelMessage).toBe('jack failed to start');
|
||||
expect(presentation?.panelMessageSeverity).toBe('warning');
|
||||
expect(presentation?.compactDetail).toBe('jack failed to start');
|
||||
expect(presentation?.compactTone).toBe('warning');
|
||||
|
|
@ -266,7 +265,66 @@ describe('buildTeamProvisioningPresentation', () => {
|
|||
memberSpawnSnapshot: undefined,
|
||||
});
|
||||
|
||||
expect(presentation?.panelMessage).toBe(`alice failed to start - ${reason}`);
|
||||
expect(presentation?.panelMessage).toBe('alice failed to start');
|
||||
});
|
||||
|
||||
it('keeps multiple failed teammate details out of the top panel', () => {
|
||||
const presentation = buildTeamProvisioningPresentation({
|
||||
progress: {
|
||||
runId: 'run-multi-failure',
|
||||
teamName: 'relay-works-18',
|
||||
state: 'ready',
|
||||
startedAt: '2026-04-13T10:00:00.000Z',
|
||||
updatedAt: '2026-04-13T10:00:08.000Z',
|
||||
message: 'Launch completed with teammate errors',
|
||||
messageSeverity: 'warning',
|
||||
pid: 4321,
|
||||
cliLogsTail: '',
|
||||
assistantOutput: '',
|
||||
},
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', agentType: 'reviewer' },
|
||||
{ name: 'tom', agentType: 'developer' },
|
||||
],
|
||||
memberSpawnStatuses: {
|
||||
alice: {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailureReason:
|
||||
'Latest assistant message msg_alice failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits',
|
||||
updatedAt: '2026-04-13T10:00:03.000Z',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
agentToolAccepted: true,
|
||||
},
|
||||
tom: {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailureReason:
|
||||
'Latest assistant message msg_tom failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits',
|
||||
updatedAt: '2026-04-13T10:00:04.000Z',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
agentToolAccepted: true,
|
||||
},
|
||||
},
|
||||
memberSpawnSnapshot: {
|
||||
expectedMembers: ['alice', 'tom'],
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 0,
|
||||
failedCount: 2,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(presentation?.panelMessage).toBe('2 teammates failed to start');
|
||||
expect(presentation?.panelMessage).not.toContain('msg_alice');
|
||||
expect(presentation?.panelMessage).not.toContain('openrouter.ai');
|
||||
});
|
||||
|
||||
it('surfaces the failed teammate reason after launch completes with errors', () => {
|
||||
|
|
@ -331,7 +389,7 @@ describe('buildTeamProvisioningPresentation', () => {
|
|||
expect(presentation?.successMessage).toBe(
|
||||
'Launch finished with errors - 1/1 teammates failed to start'
|
||||
);
|
||||
expect(presentation?.panelMessage).toContain('requested model is not available');
|
||||
expect(presentation?.panelMessage).toBe('jack failed to start');
|
||||
expect(presentation?.compactDetail).toBe('jack failed to start');
|
||||
expect(presentation?.currentStepIndex).toBe(2);
|
||||
});
|
||||
|
|
@ -467,7 +525,7 @@ describe('buildTeamProvisioningPresentation', () => {
|
|||
});
|
||||
|
||||
expect(presentation?.currentStepIndex).toBe(2);
|
||||
expect(presentation?.panelMessage).toContain('bob failed to start');
|
||||
expect(presentation?.panelMessage).toBe('bob failed to start');
|
||||
expect(presentation?.compactTone).toBe('warning');
|
||||
});
|
||||
|
||||
|
|
@ -1480,8 +1538,7 @@ describe('buildTeamProvisioningPresentation', () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(presentation?.panelMessage).toContain('jack failed to start');
|
||||
expect(presentation?.panelMessage).toContain('requested model is not available');
|
||||
expect(presentation?.panelMessage).toBe('jack failed to start');
|
||||
expect(presentation?.compactDetail).toBe('jack failed to start');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue