fix(graph): refresh runtime state in graph views
This commit is contained in:
parent
dffc527424
commit
9dd1572763
16 changed files with 513 additions and 46 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -53,6 +53,7 @@ temp/
|
|||
|
||||
eslint-fix/
|
||||
.eslintcache
|
||||
.eslintcache-fast
|
||||
remotion/*
|
||||
|
||||
.home/
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ Default local run target:
|
|||
- Do not start the browser/web dev mode for normal development or smoke checks. The browser path is limited and lacks the full desktop runtime, IPC, terminal, provider auth, and team lifecycle behavior.
|
||||
- When documenting or recommending startup commands, point contributors to the desktop app unless a task explicitly asks for browser-mode internals.
|
||||
|
||||
Fast local lint:
|
||||
|
||||
- Use `pnpm lint:fast:files -- <changed files>` for quick preflight on files you touched.
|
||||
- Use `pnpm lint:fast` for a faster source-tree lint pass when full type-aware lint is too slow.
|
||||
- `lint:fast` intentionally uses `eslint.fast.config.js` without TypeScript project-service rules. It is not a replacement for `pnpm typecheck` or the full `pnpm lint` gate.
|
||||
- Keep using `pnpm typecheck` after TypeScript changes, and use full `pnpm lint` when validating a broad PR or changing lint-sensitive architecture boundaries.
|
||||
|
||||
For new features:
|
||||
|
||||
- Default home for medium and large features: `src/features/<feature-name>/`
|
||||
|
|
|
|||
256
eslint.fast.config.js
Normal file
256
eslint.fast.config.js
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import boundaries from 'eslint-plugin-boundaries';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import security from 'eslint-plugin-security';
|
||||
import sonarjs from 'eslint-plugin-sonarjs';
|
||||
import tailwindcss from 'eslint-plugin-tailwindcss';
|
||||
import globals from 'globals';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
name: 'fast-linter-options',
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'off',
|
||||
},
|
||||
},
|
||||
|
||||
globalIgnores([
|
||||
'dist/**',
|
||||
'dist-electron/**',
|
||||
'build/**',
|
||||
'node_modules/**',
|
||||
'out/**',
|
||||
'landing/.nuxt/**',
|
||||
]),
|
||||
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
{
|
||||
name: 'fast-known-plugin-namespaces',
|
||||
plugins: {
|
||||
boundaries,
|
||||
import: importPlugin,
|
||||
security,
|
||||
sonarjs,
|
||||
tailwindcss,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'warn',
|
||||
'no-control-regex': 'warn',
|
||||
'no-unsafe-finally': 'warn',
|
||||
'no-useless-escape': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'fast-typescript',
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: false,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-undef': 'off',
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'fast-imports',
|
||||
files: ['src/**/*.{js,jsx,ts,tsx}', 'test/**/*.{ts,tsx}', 'packages/agent-graph/src/**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'simple-import-sort': simpleImportSort,
|
||||
},
|
||||
rules: {
|
||||
'simple-import-sort/imports': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
['^\\u0000'],
|
||||
['^node:'],
|
||||
['^react', '^react-dom'],
|
||||
['^@?\\w'],
|
||||
['^@/'],
|
||||
['^\\.\\.(?!/?$)', '^\\.\\./?$'],
|
||||
['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
|
||||
['^.+\\u0000$'],
|
||||
],
|
||||
},
|
||||
],
|
||||
'simple-import-sort/exports': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'fast-node-globals',
|
||||
files: ['src/main/**/*.ts', 'src/preload/**/*.ts', 'scripts/**/*.{js,mjs,ts}', 'test/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'fast-browser-globals',
|
||||
files: ['src/renderer/**/*.{ts,tsx}', 'src/features/**/renderer/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'fast-react',
|
||||
files: ['src/renderer/**/*.{tsx,ts}', 'src/features/**/renderer/**/*.{tsx,ts}'],
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
'jsx-a11y': jsxA11y,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...reactPlugin.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'react/prop-types': 'off',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/rules-of-hooks': 'warn',
|
||||
'react-hooks/globals': 'off',
|
||||
'react-hooks/purity': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/preserve-manual-memoization': 'off',
|
||||
'react-hooks/immutability': 'off',
|
||||
'jsx-a11y/click-events-have-key-events': 'warn',
|
||||
'jsx-a11y/no-static-element-interactions': 'warn',
|
||||
'jsx-a11y/label-has-associated-control': 'warn',
|
||||
'jsx-a11y/no-noninteractive-tabindex': 'warn',
|
||||
'jsx-a11y/no-autofocus': 'off',
|
||||
'react/function-component-definition': [
|
||||
'warn',
|
||||
{
|
||||
namedComponents: 'arrow-function',
|
||||
unnamedComponents: 'arrow-function',
|
||||
},
|
||||
],
|
||||
'react/jsx-key': [
|
||||
'error',
|
||||
{
|
||||
checkFragmentShorthand: true,
|
||||
checkKeyMustBeforeSpread: true,
|
||||
},
|
||||
],
|
||||
'react/self-closing-comp': ['error', { component: true, html: true }],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'fast-feature-entrypoints',
|
||||
files: [
|
||||
'src/main/**/*.{ts,tsx}',
|
||||
'src/preload/**/*.{ts,tsx}',
|
||||
'src/renderer/**/*.{ts,tsx}',
|
||||
'src/shared/**/*.{ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
'@features/*/contracts/*',
|
||||
'@features/*/core/**',
|
||||
'@features/*/main/*',
|
||||
'@features/*/preload/*',
|
||||
'@features/*/renderer/*',
|
||||
],
|
||||
message: 'Import feature public entrypoints only.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'fast-feature-core-guards',
|
||||
files: ['src/features/*/core/{domain,application}/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{ name: 'electron', message: 'Feature core must stay Electron-free.' },
|
||||
{ name: 'fastify', message: 'Feature core must stay transport-free.' },
|
||||
{ name: 'child_process', message: 'Feature core must not spawn processes directly.' },
|
||||
{
|
||||
name: 'node:child_process',
|
||||
message: 'Feature core must not spawn processes directly.',
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ['@main/*', '@preload/*', '@renderer/*'],
|
||||
message: 'Feature core must stay process-agnostic.',
|
||||
},
|
||||
{
|
||||
group: ['@features/*/main/**', '@features/*/preload/**', '@features/*/renderer/**'],
|
||||
message: 'Feature core must not import runtime or transport layers.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'fast-feature-renderer-ui-guards',
|
||||
files: ['src/features/*/renderer/ui/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{ name: '@renderer/api', message: 'renderer/ui must stay presentational.' },
|
||||
{ name: '@renderer/store', message: 'renderer/ui must stay store-free.' },
|
||||
{ name: 'electron', message: 'renderer/ui must stay Electron-free.' },
|
||||
],
|
||||
patterns: [
|
||||
{ group: ['@main/*'], message: 'renderer/ui must not import main modules.' },
|
||||
{ group: ['@renderer/store/*'], message: 'renderer/ui must stay store-free.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -51,6 +51,8 @@
|
|||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:workspace": "pnpm typecheck && pnpm --filter agent-teams-mcp typecheck && pnpm --filter agent-teams-mcp typecheck:test",
|
||||
"lint": "eslint src/ --cache --cache-location .eslintcache --cache-strategy content",
|
||||
"lint:fast": "eslint --config eslint.fast.config.js --cache --cache-location .eslintcache-fast --cache-strategy content src/",
|
||||
"lint:fast:files": "eslint --config eslint.fast.config.js --cache --cache-location .eslintcache-fast --cache-strategy content",
|
||||
"lint:mcp": "pnpm --filter agent-teams-mcp lint",
|
||||
"lint:fix": "eslint src/ --fix --cache --cache-location .eslintcache --cache-strategy content",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.39",
|
||||
"sourceRef": "v0.0.39",
|
||||
"version": "0.0.40",
|
||||
"sourceRef": "v0.0.40",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/agent-teams-ai",
|
||||
"releaseTag": "v2.0.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.39.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.40.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.39.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.40.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.39.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.40.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.39.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.40.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import type {
|
|||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamProcess,
|
||||
TeamProvisioningProgress,
|
||||
TeamViewSnapshot,
|
||||
|
|
@ -76,6 +77,7 @@ import type { LeadContextUsage } from '@shared/types/team';
|
|||
export interface TeamGraphData extends TeamViewSnapshot {
|
||||
members: ResolvedTeamMember[];
|
||||
messageFeed: InboxMessage[];
|
||||
runtimeEntriesByMember?: Record<string, TeamAgentRuntimeEntry>;
|
||||
}
|
||||
|
||||
function toGraphLaunchVisualState(
|
||||
|
|
@ -438,6 +440,7 @@ export class TeamGraphAdapter {
|
|||
): void {
|
||||
const percent = leadContext?.contextUsedPercent;
|
||||
const leadMember = data.members.find((member) => member.name === leadName);
|
||||
const runtimeEntry = data.runtimeEntriesByMember?.[leadName];
|
||||
const isTeamVisualOnline = data.isAlive || isTeamProvisioning;
|
||||
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
||||
activeTools?.[leadName],
|
||||
|
|
@ -454,6 +457,7 @@ export class TeamGraphAdapter {
|
|||
spawnLivenessSource: undefined,
|
||||
spawnRuntimeAlive: undefined,
|
||||
spawnBootstrapStalled: undefined,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: leadMember.runtimeAdvisory,
|
||||
isLaunchSettling: false,
|
||||
isTeamAlive: data.isAlive,
|
||||
|
|
@ -545,6 +549,7 @@ export class TeamGraphAdapter {
|
|||
const memberId =
|
||||
memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member);
|
||||
const spawn = spawnStatuses?.[member.name];
|
||||
const runtimeEntry = data.runtimeEntriesByMember?.[member.name];
|
||||
const activeTool = TeamGraphAdapter.#selectVisibleTool(
|
||||
activeTools?.[member.name],
|
||||
finishedVisible?.[member.name]
|
||||
|
|
@ -576,6 +581,9 @@ export class TeamGraphAdapter {
|
|||
spawnAgentToolAccepted: spawn?.agentToolAccepted,
|
||||
spawnHardFailure: spawn?.hardFailure,
|
||||
spawnLivenessKind: spawn?.livenessKind,
|
||||
spawnFirstSpawnAcceptedAt: spawn?.firstSpawnAcceptedAt,
|
||||
spawnUpdatedAt: spawn?.updatedAt,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
isTeamAlive: data.isAlive,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface GraphMemberPopoverContext {
|
|||
| null;
|
||||
teamMembers: ReturnType<typeof selectResolvedMembersForTeamName>;
|
||||
spawnEntry: AppState['memberSpawnStatusesByTeam'][string][string] | undefined;
|
||||
runtimeEntry: AppState['teamAgentRuntimeByTeam'][string]['members'][string] | undefined;
|
||||
leadActivity: AppState['leadActivityByTeam'][string] | undefined;
|
||||
progress: ReturnType<typeof getCurrentProvisioningProgressForTeam> | null;
|
||||
memberSpawnSnapshot: AppState['memberSpawnSnapshotsByTeam'][string] | undefined;
|
||||
|
|
@ -41,6 +42,9 @@ function selectGraphMemberPopoverContext(
|
|||
: null,
|
||||
teamMembers,
|
||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||
runtimeEntry: teamName
|
||||
? state.teamAgentRuntimeByTeam[teamName]?.members[memberName]
|
||||
: undefined,
|
||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
|
||||
import { useTeamAgentRuntimeWatcher } from '@renderer/components/team/useTeamAgentRuntimeWatcher';
|
||||
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
|
|
@ -73,6 +74,7 @@ export function useTeamGraphAdapter(
|
|||
members,
|
||||
messages,
|
||||
spawnStatuses,
|
||||
runtimeSnapshot,
|
||||
leadActivity,
|
||||
leadContext,
|
||||
pendingApprovals,
|
||||
|
|
@ -93,6 +95,7 @@ export function useTeamGraphAdapter(
|
|||
members: isActive ? selectResolvedMembersForTeamName(s, teamName) : EMPTY_MEMBERS,
|
||||
messages: isActive ? selectTeamMessages(s, teamName) : EMPTY_MESSAGES,
|
||||
spawnStatuses: isActive && teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
runtimeSnapshot: isActive && teamName ? s.teamAgentRuntimeByTeam[teamName] : undefined,
|
||||
leadActivity: isActive && teamName ? s.leadActivityByTeam[teamName] : undefined,
|
||||
leadContext: isActive && teamName ? s.leadContextByTeam[teamName] : undefined,
|
||||
pendingApprovals: isActive ? s.pendingApprovals : EMPTY_PENDING_APPROVALS,
|
||||
|
|
@ -113,6 +116,11 @@ export function useTeamGraphAdapter(
|
|||
}))
|
||||
);
|
||||
|
||||
useTeamAgentRuntimeWatcher({
|
||||
teamName,
|
||||
enabled: isActive,
|
||||
});
|
||||
|
||||
const pendingApprovalAgents = useMemo(() => {
|
||||
if (!isActive) {
|
||||
return EMPTY_PENDING_APPROVAL_AGENTS;
|
||||
|
|
@ -134,8 +142,9 @@ export function useTeamGraphAdapter(
|
|||
...teamSnapshot,
|
||||
members,
|
||||
messageFeed: messages,
|
||||
runtimeEntriesByMember: runtimeSnapshot?.members,
|
||||
};
|
||||
}, [members, messages, teamSnapshot]);
|
||||
}, [members, messages, runtimeSnapshot?.members, teamSnapshot]);
|
||||
|
||||
const commentReadState = useSyncExternalStore(
|
||||
isActive ? subscribe : subscribeNoop,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const ACTIVITY_SHELL_HEIGHT =
|
|||
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||
ACTIVITY_LANE.overflowHeight;
|
||||
const NEW_ACTIVITY_HIGHLIGHT_MS = 1_000;
|
||||
const INTERACTIVE_ACTIVITY_CONTROL_CLASS = 'pointer-events-auto';
|
||||
|
||||
interface GraphActivityHudProps {
|
||||
teamName: string;
|
||||
|
|
@ -456,7 +457,7 @@ export const GraphActivityHud = ({
|
|||
key={entry.graphItem.id}
|
||||
data-activity-entry-id={entry.graphItem.id}
|
||||
className={[
|
||||
'h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden rounded-md border transition-[border-color,background-color,box-shadow] duration-500',
|
||||
`${INTERACTIVE_ACTIVITY_CONTROL_CLASS} h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden rounded-md border transition-[border-color,background-color,box-shadow] duration-500`,
|
||||
isHighlighted
|
||||
? 'border-sky-300/70 bg-[rgba(14,34,62,0.56)] shadow-[0_0_0_1px_rgba(125,211,252,0.30),0_0_18px_rgba(56,189,248,0.22)]'
|
||||
: 'border-transparent',
|
||||
|
|
@ -536,7 +537,7 @@ export const GraphActivityHud = ({
|
|||
ref={(element) => {
|
||||
shellRefs.current.set(lane.node.id, element);
|
||||
}}
|
||||
className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0"
|
||||
className="pointer-events-none absolute z-10 origin-top-left select-none opacity-0"
|
||||
style={{
|
||||
width: `${laneWidth}px`,
|
||||
maxWidth: `${laneWidth}px`,
|
||||
|
|
@ -561,7 +562,7 @@ export const GraphActivityHud = ({
|
|||
{lane.overflowCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
className={`${INTERACTIVE_ACTIVITY_CONTROL_CLASS} h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]`}
|
||||
onClick={() => handleOpenOwnerActivity(lane.node)}
|
||||
>
|
||||
+{lane.overflowCount} more
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ const MemberPopoverContent = ({
|
|||
teamData,
|
||||
teamMembers,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
leadActivity,
|
||||
progress,
|
||||
memberSpawnSnapshot,
|
||||
|
|
@ -358,6 +359,9 @@ const MemberPopoverContent = ({
|
|||
spawnAgentToolAccepted: spawnEntry?.agentToolAccepted,
|
||||
spawnHardFailure: spawnEntry?.hardFailure,
|
||||
spawnLivenessKind: spawnEntry?.livenessKind,
|
||||
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
|
||||
spawnUpdatedAt: spawnEntry?.updatedAt,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false,
|
||||
isTeamAlive: teamData?.isAlive,
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ import { TeamChangesSection } from './TeamChangesSection';
|
|||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||
import { loadTeamSessionMetadata } from './teamSessionFetchGuards';
|
||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||
import { useTeamAgentRuntimeWatcher } from './useTeamAgentRuntimeWatcher';
|
||||
|
||||
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
||||
import type { KanbanSortState } from './kanban/KanbanSortPopover';
|
||||
|
|
@ -915,8 +916,6 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
|
|||
return null;
|
||||
});
|
||||
|
||||
const TEAM_AGENT_RUNTIME_REFRESH_MS = 5_000;
|
||||
|
||||
const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({
|
||||
teamName,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -928,37 +927,12 @@ const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({
|
|||
isTeamAlive?: boolean;
|
||||
isThisTabActive: boolean;
|
||||
}): null {
|
||||
const { leadActivity, fetchTeamAgentRuntime } = useStore(
|
||||
useShallow((s) => ({
|
||||
leadActivity: s.leadActivityByTeam[teamName],
|
||||
fetchTeamAgentRuntime: s.fetchTeamAgentRuntime,
|
||||
}))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThisTabActive) return;
|
||||
const shouldWatch =
|
||||
isTeamProvisioning ||
|
||||
isTeamAlive === true ||
|
||||
leadActivity === 'active' ||
|
||||
leadActivity === 'idle';
|
||||
if (!shouldWatch) return;
|
||||
|
||||
void fetchTeamAgentRuntime(teamName);
|
||||
const timer = window.setInterval(() => {
|
||||
void fetchTeamAgentRuntime(teamName);
|
||||
}, TEAM_AGENT_RUNTIME_REFRESH_MS);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [
|
||||
fetchTeamAgentRuntime,
|
||||
useTeamAgentRuntimeWatcher({
|
||||
teamName,
|
||||
enabled: isThisTabActive,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
isThisTabActive,
|
||||
leadActivity,
|
||||
teamName,
|
||||
]);
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
||||
|
|
|
|||
59
src/renderer/components/team/useTeamAgentRuntimeWatcher.ts
Normal file
59
src/renderer/components/team/useTeamAgentRuntimeWatcher.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
const TEAM_AGENT_RUNTIME_REFRESH_MS = 5_000;
|
||||
|
||||
interface TeamAgentRuntimeWatcherOptions {
|
||||
teamName: string;
|
||||
enabled: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
isTeamAlive?: boolean;
|
||||
}
|
||||
|
||||
export function useTeamAgentRuntimeWatcher({
|
||||
teamName,
|
||||
enabled,
|
||||
isTeamProvisioning,
|
||||
isTeamAlive,
|
||||
}: TeamAgentRuntimeWatcherOptions): void {
|
||||
const { leadActivity, storeIsTeamAlive, storeIsTeamProvisioning, fetchTeamAgentRuntime } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
leadActivity: s.leadActivityByTeam[teamName],
|
||||
storeIsTeamAlive: selectTeamDataForName(s, teamName)?.isAlive,
|
||||
storeIsTeamProvisioning: isTeamProvisioningActive(s, teamName),
|
||||
fetchTeamAgentRuntime: s.fetchTeamAgentRuntime,
|
||||
}))
|
||||
);
|
||||
|
||||
const effectiveIsTeamAlive = isTeamAlive ?? storeIsTeamAlive;
|
||||
const effectiveIsTeamProvisioning = isTeamProvisioning ?? storeIsTeamProvisioning;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const shouldWatch =
|
||||
effectiveIsTeamProvisioning ||
|
||||
effectiveIsTeamAlive === true ||
|
||||
leadActivity === 'active' ||
|
||||
leadActivity === 'idle';
|
||||
if (!shouldWatch) return;
|
||||
|
||||
void fetchTeamAgentRuntime(teamName);
|
||||
const timer = window.setInterval(() => {
|
||||
void fetchTeamAgentRuntime(teamName);
|
||||
}, TEAM_AGENT_RUNTIME_REFRESH_MS);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [
|
||||
effectiveIsTeamAlive,
|
||||
effectiveIsTeamProvisioning,
|
||||
enabled,
|
||||
fetchTeamAgentRuntime,
|
||||
leadActivity,
|
||||
teamName,
|
||||
]);
|
||||
}
|
||||
|
|
@ -3070,6 +3070,33 @@ export interface TeamSlice {
|
|||
|
||||
// --- Per-team launch params persistence ---
|
||||
const LAUNCH_PARAMS_PREFIX = 'team:launchParams:';
|
||||
const MESSAGES_PANEL_MODE_STORAGE_KEY = 'team:messagesPanelMode';
|
||||
const DEFAULT_MESSAGES_PANEL_MODE: TeamMessagesPanelMode = 'sidebar';
|
||||
const VALID_MESSAGES_PANEL_MODES: ReadonlySet<TeamMessagesPanelMode> = new Set([
|
||||
'sidebar',
|
||||
'inline',
|
||||
'bottom-sheet',
|
||||
'floating-composer',
|
||||
]);
|
||||
|
||||
export function loadPersistedMessagesPanelMode(): TeamMessagesPanelMode {
|
||||
try {
|
||||
const persisted = localStorage.getItem(MESSAGES_PANEL_MODE_STORAGE_KEY);
|
||||
return VALID_MESSAGES_PANEL_MODES.has(persisted as TeamMessagesPanelMode)
|
||||
? (persisted as TeamMessagesPanelMode)
|
||||
: DEFAULT_MESSAGES_PANEL_MODE;
|
||||
} catch {
|
||||
return DEFAULT_MESSAGES_PANEL_MODE;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePersistedMessagesPanelMode(mode: TeamMessagesPanelMode): void {
|
||||
try {
|
||||
localStorage.setItem(MESSAGES_PANEL_MODE_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// ignore - best-effort UI preference persistence
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentProvisioningProgressForTeam(
|
||||
state: Pick<TeamSlice, 'currentProvisioningRunIdByTeam' | 'provisioningRuns'>,
|
||||
|
|
@ -3440,10 +3467,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
toolApprovalSettings: loadToolApprovalSettings(),
|
||||
|
||||
// Messages panel UI state
|
||||
messagesPanelMode: 'sidebar' as const,
|
||||
messagesPanelMode: loadPersistedMessagesPanelMode(),
|
||||
messagesPanelWidth: 340,
|
||||
sidebarLogsHeight: 213,
|
||||
setMessagesPanelMode: (mode: TeamMessagesPanelMode) => set({ messagesPanelMode: mode }),
|
||||
setMessagesPanelMode: (mode: TeamMessagesPanelMode) => {
|
||||
savePersistedMessagesPanelMode(mode);
|
||||
set({ messagesPanelMode: mode });
|
||||
},
|
||||
setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }),
|
||||
setSidebarLogsHeight: (height: number) => set({ sidebarLogsHeight: height }),
|
||||
|
||||
|
|
|
|||
|
|
@ -254,6 +254,13 @@ describe('GraphActivityHud', () => {
|
|||
button.textContent?.includes('+1 more')
|
||||
);
|
||||
expect(moreButton).not.toBeUndefined();
|
||||
expect(moreButton?.className).toContain('pointer-events-auto');
|
||||
|
||||
const shell = host.querySelector('.z-10');
|
||||
expect(shell?.className).toContain('pointer-events-none');
|
||||
expect(host.querySelector('[data-activity-entry-id="item-1"]')?.className).toContain(
|
||||
'pointer-events-auto'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
moreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
TeamGraphAdapter,
|
||||
type TeamGraphData,
|
||||
} from '@features/agent-graph/renderer/adapters/TeamGraphAdapter';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { InboxMessage, TeamTaskWithKanban } from '@shared/types/team';
|
||||
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
InboxMessage,
|
||||
MemberSpawnStatusEntry,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types/team';
|
||||
|
||||
function createBaseTeamData(
|
||||
overrides?: Partial<TeamGraphData> & {
|
||||
|
|
@ -62,6 +66,23 @@ function findNode(graph: GraphDataPort, nodeId: string) {
|
|||
return graph.nodes.find((node) => node.id === nodeId);
|
||||
}
|
||||
|
||||
function createLiveRuntimeEntry(
|
||||
memberName: string,
|
||||
overrides: Partial<TeamAgentRuntimeEntry> = {}
|
||||
): TeamAgentRuntimeEntry {
|
||||
return {
|
||||
memberName,
|
||||
alive: true,
|
||||
restartable: true,
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
livenessKind: 'runtime_process',
|
||||
pid: 12345,
|
||||
updatedAt: '2026-03-28T19:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function adaptWithActiveTaskLogActivity(
|
||||
adapter: TeamGraphAdapter,
|
||||
teamData: TeamGraphData,
|
||||
|
|
@ -185,6 +206,62 @@ describe('TeamGraphAdapter particles', () => {
|
|||
expect(graph.layout?.mode).toBe('grid-under-lead');
|
||||
});
|
||||
|
||||
it('uses runtime entries when deriving member launch status', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const spawnStatuses: Record<string, MemberSpawnStatusEntry> = {
|
||||
alice: {
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
livenessKind: 'runtime_process',
|
||||
updatedAt: '2026-03-28T19:00:00.000Z',
|
||||
},
|
||||
};
|
||||
const teamData = createBaseTeamData({
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
agentType: 'team-lead',
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 1,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const withoutRuntime = adapter.adapt(teamData, 'my-team', spawnStatuses);
|
||||
expect(findNode(withoutRuntime, 'member:my-team:alice')?.launchStatusLabel).toBe(
|
||||
'stale runtime'
|
||||
);
|
||||
|
||||
const withRuntime = adapter.adapt(
|
||||
{
|
||||
...teamData,
|
||||
runtimeEntriesByMember: {
|
||||
alice: createLiveRuntimeEntry('alice'),
|
||||
},
|
||||
},
|
||||
'my-team',
|
||||
spawnStatuses
|
||||
);
|
||||
|
||||
expect(findNode(withRuntime, 'member:my-team:alice')?.launchStatusLabel).toBeUndefined();
|
||||
expect(findNode(withRuntime, 'member:my-team:alice')?.launchVisualState).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies saved grid owner order only in grid-under-lead mode', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
const teamData = createBaseTeamData({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
getActiveTeamPendingReplyWaits,
|
||||
hasActiveTeamPendingReplyWait,
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
loadPersistedMessagesPanelMode,
|
||||
savePersistedMessagesPanelMode,
|
||||
selectMemberMessagesForTeamMember,
|
||||
selectResolvedMemberForTeamName,
|
||||
selectResolvedMembersForTeamName,
|
||||
|
|
@ -344,6 +346,7 @@ describe('teamSlice actions', () => {
|
|||
});
|
||||
hoisted.restartMember.mockResolvedValue(undefined);
|
||||
hoisted.skipMemberForLaunch.mockResolvedValue(undefined);
|
||||
window.localStorage.removeItem('team:messagesPanelMode');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -351,6 +354,31 @@ describe('teamSlice actions', () => {
|
|||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('restores the selected messages panel mode from localStorage', () => {
|
||||
window.localStorage.setItem('team:messagesPanelMode', 'bottom-sheet');
|
||||
|
||||
const store = createSliceStore();
|
||||
|
||||
expect(store.getState().messagesPanelMode).toBe('bottom-sheet');
|
||||
});
|
||||
|
||||
it('persists messages panel mode changes and ignores invalid stored values', () => {
|
||||
const store = createSliceStore();
|
||||
|
||||
store.getState().setMessagesPanelMode('floating-composer');
|
||||
|
||||
expect(window.localStorage.getItem('team:messagesPanelMode')).toBe('floating-composer');
|
||||
expect(loadPersistedMessagesPanelMode()).toBe('floating-composer');
|
||||
|
||||
window.localStorage.setItem('team:messagesPanelMode', 'bad-mode');
|
||||
|
||||
expect(loadPersistedMessagesPanelMode()).toBe('sidebar');
|
||||
|
||||
savePersistedMessagesPanelMode('inline');
|
||||
|
||||
expect(window.localStorage.getItem('team:messagesPanelMode')).toBe('inline');
|
||||
});
|
||||
|
||||
it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => {
|
||||
const store = createSliceStore();
|
||||
const fetchTeams = vi.fn(async () => undefined);
|
||||
|
|
|
|||
Loading…
Reference in a new issue